news 2026/6/6 9:42:07

语义搜索实战:查询重写与结果排序

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
语义搜索实战:查询重写与结果排序

🦞 一只用 AI Agent 搭副业产线的程序员


你搜「Redis 内存满了怎么办」,文档里写的是「Redis OOM 处理」。关键词一个都对不上。向量搜索能匹配上——但你有没有想过,如果用户问得更模糊,向量也可能跑偏?

用户说的话跟文档里写的,经常不是一个东西。查询重写的本质:把用户的口语问题,翻译成文档库里的「黑话」。

这篇我用 3 种查询重写策略跑一遍,对比原始查询和重写后的召回率。


为什么要重写查询

真实场景:

用户问的文档里写的
「内存太大了」「内存占用过高,优化方案」
「怎么加速」「性能调优最佳实践」
「挂了怎么搞」「服务高可用与故障恢复」
「那个 key 丢了」「缓存键过期与清理机制」

你看——用户用口语、缩写、模糊描述。文档用书面语、专业术语、完整句子。向量搜索能处理一部分语义漂移,但不是万能的。

查询重写就是给向量搜索加一道前处理:先把用户的问题「翻译」成文档库里更可能匹配的表达。


实验设置

知识库:50 篇技术文档,约 300 个 chunks
测试查询:20 个真实用户提问(来自内部技术支持群)
评价指标:Recall@5(正确答案在检索 Top-5 中的比例)

不重写的基线:

Recall@5: 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]=trueallResults=append(allResults,r)}}}// 按分数排序,取 Top-Ksort.Slice(allResults,func(i,jint)bool{returnallResults[i].Score>allResults[j].Score})iflen(allResults)>topK{returnallResults[:topK]}returnallResults}

实测效果:

Recall@5: 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」

实测效果:

Recall@5: 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)}

实测效果:

Recall@5: 86%(+14%) 优点:对非常模糊的查询效果最好 缺点:每次都调一次 LLM,延迟 + 成本翻倍

三种策略横向对比

策略Recall@5额外交互次数延迟增量适合场景
不重写(基线)72%00ms查询本身很精准
查询扩展78%1 次 LLM+800ms单个关键词搜索
查询分解84%1 次 LLM+900ms复合问题
HyDE(假设答案)86%1 次 LLM+1000ms模糊、口语化查询
混合策略92%1-2 次 LLM+1500ms——

混合策略的做法:先用简单规则判断查询类型,再决定用哪种重写。

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){score+=r.TitleMatchBonus}// 文档越新,加分越多(假设新文档更相关)daysAgo:=time.Since(docDate).Hours()/24recencyBonus:=r.RecencyWeight*(1.0/(1.0+daysAgo/30))score+=recencyBonusreturnscore}

加了标题匹配和新鲜度权重后,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]=trueallResults=append(allResults,r)}}}// 3. 重排序(复合打分)ranker:=&Ranker{KeywordWeight:0.2,RecencyWeight:0.15,TitleMatchBonus:0.1,}fori,r:=rangeallResults{allResults[i].FinalScore=ranker.Score(r,query,time.Now())// 简化了日期获取}sort.Slice(allResults,func(i,jint)bool{returnallResults[i].FinalScore>allResults[j].FinalScore})iflen(allResults)>topK{returnallResults[:topK],nil}returnallResults,nil}

本篇核心收获

查询重写不是「高级优化」,是 RAG 系统的刚需。用户说人话,文档写黑话,中间需要一座桥。三种策略各有用处,混合使用效果最好——92% 的 Recall@5,不是只靠向量相似度能做到的。

下一篇我们要解决向量搜索的致命缺陷——数字、代码、人名这些「硬匹配」它天然不擅长。关键词 + 向量混合检索,是最务实的解法。

关注我,别错过。


🦞 一只用 AI Agent 搭副业产线的程序员

全平台同名:虾哥不加班
需要定制 AI 工具?来聊聊 → lob_ai

源码:GitHub - lobster-bujiaban/rag-from-scratch

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/6 9:41:07

从零搭建XTDrone仿真环境:结合PX4、ROS Melodic与Gazebo的完整工作流解析

从零构建XTDrone仿真生态&#xff1a;PX4-ROS-Gazebo深度协同技术解析当无人机开发者从单纯调用API转向深度定制飞控算法时&#xff0c;仿真环境就成为了验证思想的数字沙盘。XTDrone作为基于PX4和ROS的仿真平台&#xff0c;其价值不仅在于提供虚拟飞行环境&#xff0c;更在于构…

作者头像 李华
网站建设 2026/6/6 9:41:05

xtdic-high-temperature-system-selection-guide

XTDIC高温应变测量系统选型指南#XTDIC #高温DIC选型 #数字图像相关 #应变测量系统 #材料测试设备 #高温炉 #光学测量 #进口替代 针对200℃-1600℃全温度区间材料力学测试需求&#xff0c;对比XTDIC-HT系列三型号&#xff08;HT-Lite/HT-Pro/HT-Ultra&#xff09;的核心参数、适…

作者头像 李华
网站建设 2026/6/6 9:40:29

k8s node内核参数优化

问题一&#xff1a;文件句柄超限&#xff1a;Too many open files整个操作系统级 > 单用户级 > 容器级解决方案# 查看容器测限制 cat /etc/systemd/system/docker.service # 查看单用户限制 cat /etc/security/limits.conf 或者 ulimit -n &#xff08;软限制&#xff09…

作者头像 李华