feat(knowledge): broaden context resolution and prompt query
- Walk knowledge group children recursively so selecting a parent group pulls snippets from its descendants too. - Filter knowledge items to status='completed' across the prompt and active-snippet queries to avoid leaking in-progress documents. - Dedupe vector candidates by point id and normalized text, then pick rerank+vector results with a per-item cap so prompts stay diverse. - Tag each rendered snippet with a [Kn] reference id for prompt citing. - Build the rule knowledge query from task params (title/keywords/etc.) before falling back to rule prompt content so retrieval matches the user's actual ask. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ const (
|
||||
defaultKnowledgeQueueSize = 128
|
||||
defaultKnowledgeCleanupPoll = 15 * time.Second
|
||||
defaultKnowledgeChildGroupName = "默认目录"
|
||||
maxKnowledgeSnippetsPerItem = 2
|
||||
)
|
||||
|
||||
type KnowledgeService struct {
|
||||
@@ -743,8 +744,8 @@ func (s *KnowledgeService) ResolveContext(
|
||||
groupIDs []int64,
|
||||
query string,
|
||||
) (*KnowledgeContext, error) {
|
||||
groupIDs = normalizeInt64IDs(groupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
requestedGroupIDs := normalizeInt64IDs(groupIDs)
|
||||
if len(requestedGroupIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(query) == "" {
|
||||
@@ -757,19 +758,19 @@ func (s *KnowledgeService) ResolveContext(
|
||||
return nil, response.ErrServiceUnavailable(50352, "qdrant_unavailable", err.Error())
|
||||
}
|
||||
|
||||
groupNames, err := s.listGroupNames(ctx, tenantID, groupIDs)
|
||||
searchGroupIDs, groupNames, err := s.resolveKnowledgeSearchScope(ctx, tenantID, requestedGroupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(groupNames) == 0 {
|
||||
if len(searchGroupIDs) == 0 || len(groupNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
baseContext := &KnowledgeContext{
|
||||
GroupIDs: groupIDs,
|
||||
GroupIDs: searchGroupIDs,
|
||||
GroupNames: groupNames,
|
||||
Snippets: []KnowledgeSnippet{},
|
||||
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, nil),
|
||||
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
|
||||
}
|
||||
|
||||
vectors, err := s.provider.Embed(ctx, []string{query})
|
||||
@@ -783,7 +784,7 @@ func (s *KnowledgeService) ResolveContext(
|
||||
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
|
||||
Vector: vectors[0],
|
||||
TenantID: tenantID,
|
||||
GroupIDs: groupIDs,
|
||||
GroupIDs: searchGroupIDs,
|
||||
Limit: s.recallLimit,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -794,24 +795,7 @@ func (s *KnowledgeService) ResolveContext(
|
||||
return baseContext, nil
|
||||
}
|
||||
|
||||
candidates := make([]KnowledgeSnippet, 0, len(searchPoints))
|
||||
documents := make([]string, 0, len(searchPoints))
|
||||
for _, point := range searchPoints {
|
||||
text := strings.TrimSpace(anyString(point.Payload["text"]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, KnowledgeSnippet{
|
||||
PointID: point.ID,
|
||||
GroupID: anyInt64(point.Payload["group_id"]),
|
||||
GroupName: strings.TrimSpace(anyString(point.Payload["group_name"])),
|
||||
KnowledgeItemID: anyInt64(point.Payload["item_id"]),
|
||||
ItemName: strings.TrimSpace(anyString(point.Payload["item_name"])),
|
||||
Text: text,
|
||||
Score: point.Score,
|
||||
})
|
||||
documents = append(documents, text)
|
||||
}
|
||||
candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints))
|
||||
if len(candidates) == 0 {
|
||||
s.logKnowledgeResolve(baseContext, tenantID, query, "no_candidate_text", nil, nil)
|
||||
return baseContext, nil
|
||||
@@ -826,37 +810,25 @@ func (s *KnowledgeService) ResolveContext(
|
||||
return baseContext, nil
|
||||
}
|
||||
|
||||
documents = documents[:0]
|
||||
documents := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
documents = append(documents, candidate.Text)
|
||||
}
|
||||
|
||||
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents)))
|
||||
if err != nil {
|
||||
selected := candidates[:minInt(len(candidates), s.rerankTopN)]
|
||||
selected := selectKnowledgeCandidateSnippets(candidates, s.rerankTopN)
|
||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||
baseContext.Snippets = selected
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||
s.logKnowledgeResolve(baseContext, tenantID, query, "rerank_failed_fallback", candidates, selected)
|
||||
return baseContext, nil
|
||||
}
|
||||
|
||||
selected := make([]KnowledgeSnippet, 0, len(reranked))
|
||||
for _, item := range reranked {
|
||||
if item.Index < 0 || item.Index >= len(candidates) {
|
||||
continue
|
||||
}
|
||||
snippet := candidates[item.Index]
|
||||
snippet.Score = item.RelevanceScore
|
||||
if strings.TrimSpace(item.Text) != "" {
|
||||
snippet.Text = strings.TrimSpace(item.Text)
|
||||
}
|
||||
selected = append(selected, snippet)
|
||||
}
|
||||
|
||||
selected := selectKnowledgeRerankedSnippets(candidates, reranked, s.rerankTopN)
|
||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||
baseContext.Snippets = selected
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, groupIDs, selected)
|
||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||
s.logKnowledgeResolve(baseContext, tenantID, query, "resolved", candidates, selected)
|
||||
return baseContext, nil
|
||||
}
|
||||
@@ -948,6 +920,143 @@ func knowledgeLogPreview(value string, maxRunes int) string {
|
||||
return strings.TrimSpace(string(runes[:maxRunes])) + "..."
|
||||
}
|
||||
|
||||
func knowledgeSnippetsFromSearchPoints(points []retrieval.SearchPoint) []KnowledgeSnippet {
|
||||
if len(points) == 0 {
|
||||
return []KnowledgeSnippet{}
|
||||
}
|
||||
|
||||
snippets := make([]KnowledgeSnippet, 0, len(points))
|
||||
for _, point := range points {
|
||||
text := strings.TrimSpace(anyString(point.Payload["text"]))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
snippets = append(snippets, KnowledgeSnippet{
|
||||
PointID: strings.TrimSpace(point.ID),
|
||||
GroupID: anyInt64(point.Payload["group_id"]),
|
||||
GroupName: strings.TrimSpace(anyString(point.Payload["group_name"])),
|
||||
KnowledgeItemID: anyInt64(point.Payload["item_id"]),
|
||||
ItemName: strings.TrimSpace(anyString(point.Payload["item_name"])),
|
||||
Text: text,
|
||||
Score: point.Score,
|
||||
})
|
||||
}
|
||||
return snippets
|
||||
}
|
||||
|
||||
func dedupeKnowledgeSearchCandidates(candidates []KnowledgeSnippet) []KnowledgeSnippet {
|
||||
if len(candidates) == 0 {
|
||||
return []KnowledgeSnippet{}
|
||||
}
|
||||
|
||||
seenPoints := make(map[string]struct{}, len(candidates))
|
||||
seenTexts := make(map[string]struct{}, len(candidates))
|
||||
deduped := make([]KnowledgeSnippet, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if pointID := strings.TrimSpace(candidate.PointID); pointID != "" {
|
||||
if _, exists := seenPoints[pointID]; exists {
|
||||
continue
|
||||
}
|
||||
seenPoints[pointID] = struct{}{}
|
||||
}
|
||||
textKey := normalizeKnowledgeSnippetTextKey(candidate.Text)
|
||||
if textKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenTexts[textKey]; exists {
|
||||
continue
|
||||
}
|
||||
seenTexts[textKey] = struct{}{}
|
||||
candidate.Text = strings.TrimSpace(candidate.Text)
|
||||
deduped = append(deduped, candidate)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
func selectKnowledgeCandidateSnippets(candidates []KnowledgeSnippet, limit int) []KnowledgeSnippet {
|
||||
return selectKnowledgeRerankedSnippets(candidates, nil, limit)
|
||||
}
|
||||
|
||||
func selectKnowledgeRerankedSnippets(
|
||||
candidates []KnowledgeSnippet,
|
||||
reranked []retrieval.RerankResult,
|
||||
limit int,
|
||||
) []KnowledgeSnippet {
|
||||
if len(candidates) == 0 {
|
||||
return []KnowledgeSnippet{}
|
||||
}
|
||||
if limit <= 0 || limit > len(candidates) {
|
||||
limit = len(candidates)
|
||||
}
|
||||
|
||||
selected := make([]KnowledgeSnippet, 0, limit)
|
||||
seenPoints := make(map[string]struct{}, limit)
|
||||
seenTexts := make(map[string]struct{}, limit)
|
||||
itemCounts := make(map[int64]int, limit)
|
||||
|
||||
appendSnippet := func(snippet KnowledgeSnippet, enforceItemLimit bool) {
|
||||
if len(selected) >= limit {
|
||||
return
|
||||
}
|
||||
if pointID := strings.TrimSpace(snippet.PointID); pointID != "" {
|
||||
if _, exists := seenPoints[pointID]; exists {
|
||||
return
|
||||
}
|
||||
}
|
||||
textKey := normalizeKnowledgeSnippetTextKey(snippet.Text)
|
||||
if textKey == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seenTexts[textKey]; exists {
|
||||
return
|
||||
}
|
||||
if enforceItemLimit && snippet.KnowledgeItemID > 0 && itemCounts[snippet.KnowledgeItemID] >= maxKnowledgeSnippetsPerItem {
|
||||
return
|
||||
}
|
||||
|
||||
snippet.Text = strings.TrimSpace(snippet.Text)
|
||||
selected = append(selected, snippet)
|
||||
if pointID := strings.TrimSpace(snippet.PointID); pointID != "" {
|
||||
seenPoints[pointID] = struct{}{}
|
||||
}
|
||||
seenTexts[textKey] = struct{}{}
|
||||
if snippet.KnowledgeItemID > 0 {
|
||||
itemCounts[snippet.KnowledgeItemID]++
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range reranked {
|
||||
if item.Index < 0 || item.Index >= len(candidates) {
|
||||
continue
|
||||
}
|
||||
snippet := candidates[item.Index]
|
||||
snippet.Score = item.RelevanceScore
|
||||
if text := strings.TrimSpace(item.Text); text != "" {
|
||||
snippet.Text = text
|
||||
}
|
||||
appendSnippet(snippet, true)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
appendSnippet(candidate, true)
|
||||
}
|
||||
if len(selected) < limit {
|
||||
for _, candidate := range candidates {
|
||||
appendSnippet(candidate, false)
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
func normalizeKnowledgeSnippetTextKey(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(text), " "))
|
||||
}
|
||||
|
||||
func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||
if ctx == nil || (len(ctx.Snippets) == 0 && len(ctx.PreciseFacts) == 0) {
|
||||
return ""
|
||||
@@ -970,7 +1079,8 @@ func (s *KnowledgeService) RenderPromptSection(ctx *KnowledgeContext) string {
|
||||
|
||||
for index, snippet := range ctx.Snippets {
|
||||
label := knowledgeSnippetPromptLabel(snippet, true, index)
|
||||
block := fmt.Sprintf("%d. %s\n%s", index+1, label, snippet.Text)
|
||||
referenceID := knowledgeSnippetReferenceID(index)
|
||||
block := fmt.Sprintf("%d. [%s] %s\n%s", index+1, referenceID, label, snippet.Text)
|
||||
if sourceURI := strings.TrimSpace(derefKnowledgeString(snippet.SourceURI)); sourceURI != "" {
|
||||
block = fmt.Sprintf("%s\n来源链接: %s", block, sourceURI)
|
||||
}
|
||||
@@ -1059,6 +1169,7 @@ func (s *KnowledgeService) loadKnowledgePromptItems(
|
||||
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
||||
WHERE ki.tenant_id = $1
|
||||
AND ki.id = ANY($2::bigint[])
|
||||
AND ki.status = 'completed'
|
||||
AND ki.deleted_at IS NULL
|
||||
`, tenantID, itemIDs)
|
||||
if err != nil {
|
||||
@@ -1188,6 +1299,7 @@ func (s *KnowledgeService) loadKnowledgePromptItemsByGroupIDs(
|
||||
FROM knowledge_items ki
|
||||
JOIN knowledge_groups kg ON kg.id = ki.group_id AND kg.deleted_at IS NULL
|
||||
WHERE ki.tenant_id = $1
|
||||
AND ki.status = 'completed'
|
||||
AND ki.deleted_at IS NULL
|
||||
AND ki.group_id IN (SELECT id FROM selected_groups)
|
||||
ORDER BY
|
||||
@@ -1264,6 +1376,10 @@ func knowledgeSnippetPromptLabel(snippet KnowledgeSnippet, fallbackToDefault boo
|
||||
return label
|
||||
}
|
||||
|
||||
func knowledgeSnippetReferenceID(index int) string {
|
||||
return fmt.Sprintf("K%d", index+1)
|
||||
}
|
||||
|
||||
func extractKnowledgePreciseFacts(text string) []string {
|
||||
normalized := normalizeKnowledgeMarkdown(text)
|
||||
if normalized == "" {
|
||||
@@ -2323,6 +2439,7 @@ func (s *KnowledgeService) filterActiveKnowledgeSnippets(
|
||||
FROM knowledge_items
|
||||
WHERE tenant_id = $1
|
||||
AND id = ANY($2::bigint[])
|
||||
AND status = 'completed'
|
||||
AND deleted_at IS NULL
|
||||
`, tenantID, itemIDs)
|
||||
if err != nil {
|
||||
@@ -2622,37 +2739,62 @@ func (s *KnowledgeService) listKnowledgeItemsByIDs(ctx context.Context, tenantID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeService) listGroupNames(ctx context.Context, tenantID int64, groupIDs []int64) ([]string, error) {
|
||||
func (s *KnowledgeService) resolveKnowledgeSearchScope(ctx context.Context, tenantID int64, groupIDs []int64) ([]int64, []string, error) {
|
||||
groupIDs = normalizeInt64IDs(groupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
return []string{}, nil
|
||||
return []int64{}, []string{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH RECURSIVE selected_groups AS (
|
||||
SELECT id, name, parent_id, sort_order, created_at
|
||||
FROM knowledge_groups
|
||||
WHERE tenant_id = $1
|
||||
AND id = ANY($2::bigint[])
|
||||
AND deleted_at IS NULL
|
||||
UNION
|
||||
SELECT child.id, child.name, child.parent_id, child.sort_order, child.created_at
|
||||
FROM knowledge_groups child
|
||||
JOIN selected_groups parent ON child.parent_id = parent.id
|
||||
WHERE child.tenant_id = $1
|
||||
AND child.deleted_at IS NULL
|
||||
)
|
||||
SELECT id, name
|
||||
FROM knowledge_groups
|
||||
WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL
|
||||
FROM selected_groups
|
||||
ORDER BY sort_order, created_at, id
|
||||
`, tenantID, groupIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
|
||||
return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", "failed to query knowledge groups")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
resolvedIDs := make([]int64, 0, len(groupIDs))
|
||||
names := make([]string, 0, len(groupIDs))
|
||||
seenIDs := make(map[int64]struct{}, len(groupIDs))
|
||||
seenNames := make(map[string]struct{}, len(groupIDs))
|
||||
for rows.Next() {
|
||||
var (
|
||||
groupID int64
|
||||
name string
|
||||
)
|
||||
if err := rows.Scan(&groupID, &name); err != nil {
|
||||
return nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error())
|
||||
return nil, nil, response.ErrInternal(50050, "knowledge_group_query_failed", err.Error())
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
names = append(names, strings.TrimSpace(name))
|
||||
if groupID > 0 {
|
||||
if _, exists := seenIDs[groupID]; !exists {
|
||||
seenIDs[groupID] = struct{}{}
|
||||
resolvedIDs = append(resolvedIDs, groupID)
|
||||
}
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
if _, exists := seenNames[name]; !exists {
|
||||
seenNames[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
return resolvedIDs, names, nil
|
||||
}
|
||||
|
||||
func normalizeInt64IDs(ids []int64) []int64 {
|
||||
@@ -2677,8 +2819,12 @@ func normalizeInt64IDs(ids []int64) []int64 {
|
||||
|
||||
func anyString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return typed
|
||||
case []byte:
|
||||
return string(typed)
|
||||
default:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user