feat(knowledge): multi-query retrieval with LLM query rewrite

Add a shared structured query builder (knowledge_query.go) and switch
all generation paths to it, so retrieval queries carry task intent
(type, brand, region, keywords, key points) instead of a raw prompt.

ResolveContext now rewrites the query into multiple sub-questions via
the URL-markdown model, embeds and searches each, and merges/dedupes
candidates before rerank; falls back to parsed fallback questions when
rewrite is unavailable or returns too few. Resolve logs gain query
list, count, and rewrite stage/model for observability.

- knowledge_query.go/_test.go: BuildKnowledgeQuery + intent normalization
- knowledge_service.go: per-query embed/search loop, query rewrite, logs
- template_prompt/template_service/prompt_generate/article_imitation/
  kol_generation_worker: adopt the shared query builder
- generation_observability: richer task error logging
This commit is contained in:
2026-06-02 14:50:12 +08:00
parent 85bb373a8b
commit ba2f117265
11 changed files with 734 additions and 102 deletions
+150 -18
View File
@@ -836,9 +836,34 @@ func (s *KnowledgeService) ResolveContext(
if len(requestedGroupIDs) == 0 {
return nil, nil
}
if strings.TrimSpace(query) == "" {
queries := normalizeKnowledgeResolveQueries(query)
if len(queries) == 0 {
return nil, nil
}
queryRewriteStage := "fallback"
queryRewriteModel := strings.TrimSpace(s.runtimeConfig().URLMarkdownModel)
if rewritten, err := s.rewriteKnowledgeResolveQueries(ctx, query); err != nil {
if s.logger != nil {
s.logger.Warn("knowledge query rewrite failed",
zap.String("query", knowledgeLogPreview(query, 600)),
zap.String("query_rewrite_model", queryRewriteModel),
zap.Strings("fallback_queries", queries),
zap.Error(err),
)
}
} else if len(rewritten) >= maxKnowledgeQueryQuestions {
queries = rewritten
queryRewriteStage = "model"
} else if len(rewritten) > 0 && s.logger != nil {
s.logger.Warn("knowledge query rewrite returned insufficient questions",
zap.String("query", knowledgeLogPreview(query, 600)),
zap.String("query_rewrite_model", queryRewriteModel),
zap.Strings("model_queries", rewritten),
zap.Int("model_query_count", len(rewritten)),
zap.Strings("fallback_queries", queries),
)
}
rerankQuery := strings.Join(queries, "\n")
if err := s.provider.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50351, "retrieval_unavailable", err.Error())
}
@@ -861,32 +886,39 @@ func (s *KnowledgeService) ResolveContext(
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
}
vectors, err := s.provider.Embed(ctx, []string{query})
vectors, err := s.provider.Embed(ctx, queries)
if err != nil {
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error())
}
if len(vectors) == 0 || len(vectors[0]) == 0 {
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding vector is empty")
if len(vectors) != len(queries) {
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding result count mismatch")
}
runtimeCfg := s.runtimeConfig()
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
Vector: vectors[0],
TenantID: tenantID,
GroupIDs: searchGroupIDs,
Limit: runtimeCfg.RecallLimit,
})
if err != nil {
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
searchPoints := make([]retrieval.SearchPoint, 0, len(queries)*runtimeCfg.RecallLimit)
for index, vector := range vectors {
if len(vector) == 0 {
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", fmt.Sprintf("embedding vector is empty at query index %d", index))
}
points, err := s.store.Search(ctx, retrieval.SearchRequest{
Vector: vector,
TenantID: tenantID,
GroupIDs: searchGroupIDs,
Limit: runtimeCfg.RecallLimit,
})
if err != nil {
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
}
searchPoints = append(searchPoints, points...)
}
if len(searchPoints) == 0 {
s.logKnowledgeResolve(baseContext, tenantID, query, "no_search_points", nil, nil)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_search_points", queryRewriteStage, queryRewriteModel, nil, nil)
return baseContext, nil
}
candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints))
if len(candidates) == 0 {
s.logKnowledgeResolve(baseContext, tenantID, query, "no_candidate_text", nil, nil)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_candidate_text", queryRewriteStage, queryRewriteModel, nil, nil)
return baseContext, nil
}
@@ -895,7 +927,7 @@ func (s *KnowledgeService) ResolveContext(
return nil, err
}
if len(candidates) == 0 {
s.logKnowledgeResolve(baseContext, tenantID, query, "no_active_candidates", nil, nil)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_active_candidates", queryRewriteStage, queryRewriteModel, nil, nil)
return baseContext, nil
}
@@ -904,13 +936,13 @@ func (s *KnowledgeService) ResolveContext(
documents = append(documents, candidate.RerankText())
}
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(runtimeCfg.RerankTopN, len(documents)))
reranked, err := s.provider.Rerank(ctx, rerankQuery, documents, minInt(runtimeCfg.RerankTopN, len(documents)))
if err != nil {
selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN)
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
s.logKnowledgeResolve(baseContext, tenantID, query, "rerank_failed_fallback", candidates, selected)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected)
return baseContext, nil
}
@@ -918,21 +950,48 @@ func (s *KnowledgeService) ResolveContext(
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
baseContext.Snippets = selected
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
s.logKnowledgeResolve(baseContext, tenantID, query, "resolved", candidates, selected)
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected)
return baseContext, nil
}
func (s *KnowledgeService) rewriteKnowledgeResolveQueries(ctx context.Context, query string) ([]string, error) {
if s == nil || s.llmClient == nil {
return []string{}, nil
}
prompt := buildKnowledgeQueryRewritePrompt(query)
if prompt == "" {
return []string{}, nil
}
runtimeCfg := s.runtimeConfig()
result, err := s.llmClient.Generate(ctx, llm.GenerateRequest{
Model: runtimeCfg.URLMarkdownModel,
Prompt: prompt,
Timeout: 15 * time.Second,
MaxOutputTokens: 600,
ResponseFormat: &llm.ResponseFormat{
Type: llm.ResponseFormatTypeJSONObject,
},
}, nil)
if err != nil {
return nil, err
}
return parseKnowledgeQueryRewriteOutput(result.Content), nil
}
func (s *KnowledgeService) logKnowledgeResolve(
knowledgeCtx *KnowledgeContext,
tenantID int64,
query string,
stage string,
queryRewriteStage string,
queryRewriteModel string,
candidates []KnowledgeSnippet,
selected []KnowledgeSnippet,
) {
if s == nil || s.logger == nil || knowledgeCtx == nil {
return
}
queries := normalizeKnowledgeResolveQueries(query)
s.logger.Info("knowledge resolve result",
zap.String("stage", strings.TrimSpace(stage)),
@@ -940,6 +999,10 @@ func (s *KnowledgeService) logKnowledgeResolve(
zap.Int64s("group_ids", knowledgeCtx.GroupIDs),
zap.Strings("group_names", knowledgeCtx.GroupNames),
zap.String("query", knowledgeLogPreview(query, 600)),
zap.Strings("queries", queries),
zap.Int("query_count", len(queries)),
zap.String("query_rewrite_stage", strings.TrimSpace(queryRewriteStage)),
zap.String("query_rewrite_model", strings.TrimSpace(queryRewriteModel)),
zap.Int("candidate_count", len(candidates)),
zap.Int("selected_count", len(selected)),
zap.Int("precise_fact_count", len(knowledgeCtx.PreciseFacts)),
@@ -947,6 +1010,75 @@ func (s *KnowledgeService) logKnowledgeResolve(
)
}
func normalizeKnowledgeResolveQueries(query string) []string {
lines := strings.Split(strings.TrimSpace(query), "\n")
queries := make([]string, 0, minInt(maxKnowledgeQueryQuestions, len(lines)))
seen := make(map[string]struct{}, len(lines))
for index, line := range lines {
if strings.TrimSuffix(strings.TrimSpace(line), "") != "兜底检索问题" {
continue
}
for _, fallbackLine := range lines[index+1:] {
text := strings.TrimSpace(fallbackLine)
if text == "" {
continue
}
if !strings.HasPrefix(text, "-") {
break
}
appendNormalizedKnowledgeResolveQuery(&queries, seen, strings.TrimSpace(strings.TrimPrefix(text, "-")))
if len(queries) >= maxKnowledgeQueryQuestions {
return queries
}
}
if len(queries) > 0 {
return queries
}
}
inFallbackQuestions := false
for _, line := range lines {
text := strings.TrimSpace(line)
if text == "" {
continue
}
if strings.TrimSuffix(text, "") == "兜底检索问题" {
inFallbackQuestions = true
continue
}
if strings.Contains(text, "") && !strings.HasPrefix(text, "-") && !inFallbackQuestions {
continue
}
if inFallbackQuestions && !strings.HasPrefix(text, "-") {
continue
}
text = strings.TrimPrefix(text, "-")
text = strings.TrimSpace(text)
appendNormalizedKnowledgeResolveQuery(&queries, seen, text)
if len(queries) >= maxKnowledgeQueryQuestions {
break
}
}
return queries
}
func appendNormalizedKnowledgeResolveQuery(queries *[]string, seen map[string]struct{}, text string) {
if queries == nil || len(*queries) >= maxKnowledgeQueryQuestions {
return
}
text = strings.TrimSpace(text)
if text == "" {
return
}
key := strings.ToLower(strings.Join(strings.Fields(text), " "))
if _, exists := seen[key]; exists {
return
}
seen[key] = struct{}{}
*queries = append(*queries, text)
}
// for debug
func summarizeKnowledgeSnippetsForLog(snippets []KnowledgeSnippet) []map[string]any {
if len(snippets) == 0 {