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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user