语义搜索实战:查询重写与结果排序 一只用 AI Agent 搭副业产线的程序员你搜「Redis 内存满了怎么办」文档里写的是「Redis OOM 处理」。关键词一个都对不上。向量搜索能匹配上——但你有没有想过如果用户问得更模糊向量也可能跑偏用户说的话跟文档里写的经常不是一个东西。查询重写的本质把用户的口语问题翻译成文档库里的「黑话」。这篇我用 3 种查询重写策略跑一遍对比原始查询和重写后的召回率。为什么要重写查询真实场景用户问的文档里写的「内存太大了」「内存占用过高优化方案」「怎么加速」「性能调优最佳实践」「挂了怎么搞」「服务高可用与故障恢复」「那个 key 丢了」「缓存键过期与清理机制」你看——用户用口语、缩写、模糊描述。文档用书面语、专业术语、完整句子。向量搜索能处理一部分语义漂移但不是万能的。查询重写就是给向量搜索加一道前处理先把用户的问题「翻译」成文档库里更可能匹配的表达。实验设置知识库50 篇技术文档约 300 个 chunks测试查询20 个真实用户提问来自内部技术支持群评价指标Recall5正确答案在检索 Top-5 中的比例不重写的基线Recall5: 72%20 个问题中14 个的正确答案在前 5 名策略一查询扩展Query Expansion思路用 LLM 根据用户问题生成 3-5 个同义表达每个都去搜合并去重。funcexpandQuery(llm*llm.Client,querystring)[]string{prompt:fmt.Sprintf(将以下技术问题改写为3个不同表述覆盖关键词和专业术语。 每个改写一行不要编号。 问题%s 改写,query,)response,_:llm.Chat([]llm.Message{{Role:user,Content:prompt},},0.3,150)lines:strings.Split(strings.TrimSpace(response),\n)returnappend([]string{query},lines...)}funcsearchWithExpansion(embedder*embedder.Embedder,retriever*retriever.QdrantRetriever,querystring,topKint,)[]retriever.SearchResult{queries:expandQuery(llmClient,query)// 用 map 去重同一条文档可能被多个 query 检索到seen:make(map[string]bool)varallResults[]retriever.SearchResultfor_,q:rangequeries{vec,_:embedder.Embed(q)results,_:retriever.Search(vec,topK)for_,r:rangeresults{if!seen[r.Text]{seen[r.Text]trueallResultsappend(allResults,r)}}}// 按分数排序取 Top-Ksort.Slice(allResults,func(i,jint)bool{returnallResults[i].ScoreallResults[j].Score})iflen(allResults)topK{returnallResults[:topK]}returnallResults}实测效果Recall5: 78%6% 优点实现简单不需要理解文档结构 缺点调用 LLM 多花 1 次成本增加策略二查询分解Query Decomposition思路复杂问题拆成子问题分别检索合并。funcdecomposeQuery(llm*llm.Client,querystring)[]string{prompt:fmt.Sprintf(判断以下问题是否为复合问题包含多个子问题。 如果是拆分出子问题列表每行一个。如果不是只返回原问题。 不要编号不要解释。 问题%s,query,)response,_:llm.Chat([]llm.Message{{Role:user,Content:prompt},},0.0,200)lines:strings.Split(strings.TrimSpace(response),\n)iflen(lines)1{return[]string{query}// 不是复合问题}returnlines}实例用户问「Redis 集群模式下如果主节点挂了数据会丢吗怎么恢复」 分解结果 - 「Redis 集群主节点故障数据丢失风险」 - 「Redis 集群故障恢复流程」 - 「Redis 集群数据持久化 RDB AOF」实测效果Recall5: 84%12% 优点复合问题效果极好子问题检索更精准 缺点不是所有问题都需要分解简单问题反而被拆坏策略三假设答案HyDE思路先让 LLM 猜一个答案拿这个「假设答案」的向量去搜。原理假设答案的内容风格跟文档库更接近书面语、专业术语所以它的向量能更好地匹配文档。funcgenerateHypotheticalAnswer(llm*llm.Client,querystring,)string{prompt:fmt.Sprintf(你是一位资深后端工程师。请用一段技术文档风格的话 回答以下问题。只需写一个段落使用专业术语。 问题%s 技术回答一段话,query,)response,_:llm.Chat([]llm.Message{{Role:user,Content:prompt},},0.2,300)returnresponse}funcsearchWithHyDE(embedder*embedder.Embedder,retriever*retriever.QdrantRetriever,llm*llm.Client,querystring,topKint,)[]retriever.SearchResult{// 1. 生成假设答案hypothetical:generateHypotheticalAnswer(llm,query)// 2. 用假设答案的向量去搜不用原问题vec,_:embedder.Embed(hypothetical)returnretriever.Search(vec,topK)}实测效果Recall5: 86%14% 优点对非常模糊的查询效果最好 缺点每次都调一次 LLM延迟 成本翻倍三种策略横向对比策略Recall5额外交互次数延迟增量适合场景不重写基线72%00ms查询本身很精准查询扩展78%1 次 LLM800ms单个关键词搜索查询分解84%1 次 LLM900ms复合问题HyDE假设答案86%1 次 LLM1000ms模糊、口语化查询混合策略92%1-2 次 LLM1500ms——混合策略的做法先用简单规则判断查询类型再决定用哪种重写。funcsmartRewrite(llm*llm.Client,querystring)([]string,string){runes:[]rune(query)// 简单规则判断iflen(runes)15{// 很短 → 扩展加点上下文returnexpandQuery(llm,query),expansion}ifstrings.Contains(query,)strings.Contains(query,还){// 多问句 → 分解returndecomposeQuery(llm,query),decomposition}// 默认 → HyDEreturn[]string{generateHypotheticalAnswer(llm,query)},hyde}smartRewrite的判断逻辑很粗糙但已经比只用一种策略提升了 6 个点的召回率。生产环境中你可以做得更精细。检索结果排序优化重写查询找到更多文档后还要对结果排序。别只依赖向量相似度分数——加上文档的元信息权重。typeRankerstruct{// BM25 权重下篇讲KeywordWeightfloat64// 文档新鲜度权重越新越靠前RecencyWeightfloat64// 标题匹配加分TitleMatchBonusfloat64}func(r*Ranker)Score(doc SearchResult,querystring,docDate time.Time,)float64{score:doc.Score// 向量相似度基础分// 标题包含查询关键词 → 加分ifstrings.Contains(doc.DocName,query){scorer.TitleMatchBonus}// 文档越新加分越多假设新文档更相关daysAgo:time.Since(docDate).Hours()/24recencyBonus:r.RecencyWeight*(1.0/(1.0daysAgo/30))scorerecencyBonusreturnscore}加了标题匹配和新鲜度权重后Top-3 准确率从 82% 提到了 88%——5 行代码换了 6 个百分点。完整搜索流程funcSearch(querystring,topKint,)([]SearchResult,error){// 1. 查询重写rewriteQueries,_:smartRewrite(llmClient,query)// 2. 多查询检索seen:make(map[string]bool)varallResults[]SearchResultfor_,q:rangerewriteQueries{vec,_:embedder.Embed(q)results,_:qdrant.Search(vec,topK*2)for_,r:rangeresults{if!seen[r.Text]{seen[r.Text]trueallResultsappend(allResults,r)}}}// 3. 重排序复合打分ranker:Ranker{KeywordWeight:0.2,RecencyWeight:0.15,TitleMatchBonus:0.1,}fori,r:rangeallResults{allResults[i].FinalScoreranker.Score(r,query,time.Now())// 简化了日期获取}sort.Slice(allResults,func(i,jint)bool{returnallResults[i].FinalScoreallResults[j].FinalScore})iflen(allResults)topK{returnallResults[:topK],nil}returnallResults,nil}本篇核心收获查询重写不是「高级优化」是 RAG 系统的刚需。用户说人话文档写黑话中间需要一座桥。三种策略各有用处混合使用效果最好——92% 的 Recall5不是只靠向量相似度能做到的。下一篇我们要解决向量搜索的致命缺陷——数字、代码、人名这些「硬匹配」它天然不擅长。关键词 向量混合检索是最务实的解法。关注我别错过。 一只用 AI Agent 搭副业产线的程序员全平台同名虾哥不加班需要定制 AI 工具来聊聊 → lob_ai源码GitHub - lobster-bujiaban/rag-from-scratch