夜雨聆风学习资料网

ARTICLE · 1291340

文档的答案RAG 就是搜不到,检索到底出了什么问题?

文档的答案RAG 就是搜不到,检索到底出了什么问题?

本周我们来探讨一个实际问题:RAG听不懂“人话”,资料一多就查不准、找不到,进而答非所问,智能化体现不出来。我们常常会有一个感受:RAG库文档里面明明有相关资料,可是大模型就是搜索不到,答不准。比如:政务窗口的老百姓问“我的房子面积超标了咋办”,明明政策文件里写了“宅基地超占处理”相关政策,可是对AI翻来覆去就是找不到地方,怎么办?

1. 上周遗留的问题

上周结尾我们埋了雷:前两周的召回实验,全部默认用户的问题用词非常专业,和资料库正文匹配度非常高。但实际上真实的用户不可能正好使用我们文档中的专业词汇来“明知故问”,一旦用户口语化提问,我们的查询就崩了。这篇我们来专心拆雷:资料库建好之后,问题查询侧如何优化不裸奔?

2. 解决问题要弄懂的几个关键概念

(这部分来自我第 11 周的学习笔记)

用户的提问不能直接用于检索查询

拿我们自己的测试语料来说:库里《06_聊天历史管理》正文写的是"上下文窗口""历史消息压缩"。但我们的用户大概率不会说“上下文窗口不够了,我的历史消息如何压缩?”,他们可能会说"AI 聊着聊着就忘了前面说的话,咋办?"。

 你看,“忘事”两个字在我们的库里一次都没出现,向量余弦的相似度直接干趴下了。 除了专业词汇之外,用户可能还会使用代指,“它支持多长的输入?”它是指哪个模型,检索器不知道,无从查起。 

还有一种情况:用户问题一句话塞三件事,“怎么给 AI 外挂技能,要花钱吗,用什么模型跑”,要回答这个问题可能需要查三篇文档,而检索器在某一篇文档中永远无法拿到完整的结果。

那如何把用户的话变成适合检索的查询?以下几种方法适应不同的情况,我们需要混着用:

手法
干什么
例子
查询重写
口语换术语、补全指代
"AI 老忘事" → "聊天历史 上下文 丢失"
多查询扩展
一个意图换几种说法各查一遍,结果合并去重
"AI 怎么收费"同时查"Token 计费""模型价格""免费额度"
HyDE
让 LLM 先写个假答案,拿假答案的向量去搜
问"怎么收费",先编一段"计费按输入输出 Token……"再拿去检索
Step-Back
先退一步问背景问题,拿背景材料垫底再答原问题
"RAG 怎么提升准确性"先查"LLM 没有外部知识时的局限"

其中HyDE 最反直觉:为什么让AI编一个假答案会好使?

因为在向量空间中,问句和陈述句本来就属于两个不同的区域,拿用户的问题直接和文档比相似度是区域比较,拿到结果的概率自然不高。假答案和文档同为陈述句,天然挨得近。甚至假答案都不一定要对,它只要"像一篇这个领域的文档",主题和术语对了就行,就能把检索拉到正确文档附近。当然风险也在这里:如果语料是私有冷门领域,LLM 没有相关常识,假答案的术语全靠编,那最后检索就肯定整个跑偏了。 

基于这个知识点,我们就要针对性的编排我们的RAG,如果我们的业务语料是公开的能查询到的信息,HyDE 是个好方法;但换成我们私有领域的资料比如自然资源内部政策库,就最好老老实实用查询重写加术语词典的模式。

关于一句话塞了几个子问题的情况,我们也有专门的拆分手法。 

还是以“怎么给 AI 外挂技能,要花钱吗,用什么模型跑”这个作为例子:先拆成三个子问题,各自独立检索,最后把三份证据拼在一起交给模型。这实际上就是多查询扩展的流程:一个入口进去,几个查询出来,并行检索、合并结果。区别只在那几个查询怎么来的:多查询是给一个意图换几种说法,拆分是把一个复合问题掰成几个意图。 

最难办的是子问题有依赖:问“营收同比涨了多少”,得先查出今年的数,再查出去年的数,最后一步的查询词要用前一步的答案。这种串行拆分得让模型边查边想,是 Agentic RAG 的活,我们以后再说。

关键词查询和向量查询缺一不可

关键词查询有个特点,它认字面:“错误码”、“型号”、“字段名”、“结构化输出”这种专有名词,一个字都不能差。也正因为只认字面,它查得快、查得准,这是它的强项。当然代价就是用户换个说法它就瞎了。

向量查询反过来,它不认字面认语义:“给 AI 外挂技能”和“Agent函数调用”在字面上一个字都不沾,但向量知道它们离得近,这是它的强项。代价是对“不支持”“禁用”这类否定词经常装看不见,“支持 5G”和“不支持 5G”在向量眼里几乎是同一句话;真要精确查某个型号、编号,它也帮不上忙。 

基于它俩的特性,我们设计查询的时候必须坚持两条腿并行走,关键词查询保证该命中的词不丢,向量查询确保换种说法也找得到。但两路查询结果最后要合并,就不能简单把结果直接相加。因为向量查询基于余弦,相似度是有界限的,一堆文档挤在 0.7 到 0.8 之间;而关键字查询基于分数,它没有上界,可能是 3 也可能是 30,直接相加等于让关键字查询的优先级无限提升进而一锤定音。通常这里我们使用RRF(倒数排名融合)进行处理:两路都排在前面的文档优先,只在一路露脸的靠后。具体怎么打分,我们第 3 节看。

重排是最后一道精挑

查询动作把相关文档捞回来了,但文档顺序很粗糙:排在第一的,未必是最能回答这个问题的。病根在向量查询的打分方式:文档离线算好存起来,问题和文档各自编码成一个向量,算个相似度,查询时比一下完事,打分的时候两边根本没关联起来。虽然速度快但整篇文档被压成了一个向量,细节全被平均掉,“支持 X”和“不支持 X”压出来可能差不多。

要想排得准,就得让问题和文档关联。重排(Rerank)就是干这个的:换一个更精的打分模型,把候选挨个和问题放在一起重新打分、重新排序。这类模型最常用的是 Cross-Encoder,它把问题和文档拼成一条输入塞进模型,两边每个词都能互相看见。词面相近、意图不同的它能分辨,否定词也读得懂。当然它也有它的问题,它很准,但是准是拿速度换的:输入里含着问题,用户没问之前无法提前算,每个候选都要现跑一次,所以它只用来给融合后的几十条候选精排,全库扫是扫不动的。

重排模型不止Cross-Encoder一家,按“问题和文档怎么见面”分几类,各有各的场合:

类型
问题和文档怎么见面
适合什么场合
Bi-Encoder(就是前面的向量查询)
不见面,各自成向量比相似度,文档可离线
全库召回,快
Cross-Encoder
拼成一条输入全交互,只能现算
几十条候选精排,最准
ColBERT
文档侧存 token 级多向量,查询时轻量交互
候选多、延迟敏感,存储换速度
LLM 重排(RankGPT 一类)
把候选列表发给大模型让它排序
不想装本地模型,按次付费

做常规中文 RAG,默认选 Cross-Encoder 就够,bge-reranker 是这类里的常客,第 3 节代码用的就是它。

重排把顺序排对了,还剩最后一道选择题:排好的名单,取几条送给模型? 这里正好把 Top-K 正式讲清楚,这个词前面几篇反复出现,一直没展开。它的意思是从排好序的候选里取前 K 条:候选按分数从高到低排好,从头切 K 条交给模型。我们一直说的 Top-3,就是 K 等于 3。放到我们这条链路上:关键词和向量两路各自捞回几十条,合并去重成三五十条的候选池,重排模型挨个打完分,最后放行的就是这 3 条。这个 K 不能贪大,如果K的条数一多,旧版本文档、主题相近的片段全跟着混进来,模型看着一堆证据容易挑花眼,答非所问的风险反而更大。

3. 代码实现

核心思路:改写负责翻译,双路负责兜底,RRF 负责合并,重排负责精挑,每一步的输入输出全部落日志。

上周的 Chunk、VectorStore、RagPipeline、登记簿全部沿用,这周只动查询侧,给它加四段。

① 查询改写:LLM 当翻译,失败回退原问题

REWRITE_PROMPT = """把用户的口语问题改写成适合知识库检索的查询。要求:口语说法换成文档里可能使用的正式术语;“它/这个”这类指代补全;一句话里有多个意图就先拆成子问题,否则换 2~3 种说法。只输出 JSON:{{"rewrite": "...", "queries": ["...", "..."]}}拿不准就原样返回原问题。用户问题:{q}"""defrewrite_query(question: str, provider) -> tuple[str, list[str]]:"""LLM 翻译,失败必须能回退原问题。"""try:        resp = provider.chat(REWRITE_SYSTEM, REWRITE_PROMPT.format(q=question))        data = json.loads(extract_json(resp.content))    # 第02周同款 JSON 护栏        rewrite = (data.get("rewrite") or"").strip() or question        variants = [v.strip() for v in data.get("queries", []) if v.strip()][:3]return rewrite, [question, *variants]            # 原问题永远占一路except Exception:                # 模型挂了、格式烂了,都走这return question, [question]

关键设计:变体列表里永远保留原问题,改写跑偏时原始路还在参与检索;解析失败直接回退,改写是增强,不能变成依赖。

② 关键词查询路:手写迷你 BM25

关键词查询这一路的打分公式叫 BM25,是检索界的老前辈:一个词在这篇文档里出现得越多,这篇文档的得分越高;这个词如果在库里到处都是,就不值钱。打分前得先把文本切成"词",中文没有天然的词边界,我们偷个懒、不引分词库,把相邻两个字配成一对当词用,使用 2-gram——"结构化输出"进来,切成"结构、构化、化输、输出"四块。

from collections import Counterdefterms(s: str) -> Counter:"""中文不分词,字符 2-gram 当词(偷懒但能跑,分词的坑见 §4)。"""return Counter(s[i:i + 2] for i in range(len(s) - 1))defbm25_scores(query: str, chunks: list[Chunk],                k1: float = 1.5, b: float = 0.75) -> list[float]:    q, docs = terms(query), [terms(c.text) for c in chunks]    N, avgdl = len(docs), sum(map(len, docs)) / max(len(docs), 1)    df = Counter(t for d in docs for t in d)             # 每个词出现在几篇    out = []for d in docs:        s = 0.0for t in q:if t notin d:continue            idf = math.log((N - df[t] + 0.5) / (df[t] + 0.5) + 1)            s += idf * d[t] * (k1 + 1) / (d[t] + k1 * (1 - b + b * len(d) / avgdl))        out.append(s)return out

关键设计:不引分词库,先跑通再说;BM25 分数无上界,这正是融合必须用排名而不能用分数的原因。

③ RRF 融合:五行公式

到这一步,两路已经各自交出一份排名:BM25 给关键词路排了一份,余弦给向量路排了一份,两份名单的先后常常对不上。RRF 负责把它们合成一份,规则只有一条:只看名次,不看分数。

defrrf_fuse(ranklists: list[list[str]], k: int = 60) -> dict[str, float]:"""倒数排名融合:score = Σ 1/(k + 名次)。只看排名不看分数,    两路分数量纲不可比的问题从根上绕开。"""    scores: dict[str, float] = {}for ranking in ranklists:            # 每路是 chunk_id 的有序列表for rank, cid in enumerate(ranking, 1):            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)return scores

关键设计:融合分只用来排序,不进拒答阈值。

④ Cross-Encoder 重排:候选池里精挑

from sentence_transformers import CrossEncoder   # 新依赖:torch + sentence-transformersclassCrossEncoderReranker:"""Cross-Encoder:query 和文档拼成一条输入打一个分,准,但只能现算。"""def__init__(self, model: str = "models/bge-reranker-v2-m3"):        self.ce = CrossEncoder(model, max_length=512)    # 本地目录或模型名都能加载defrerank(self, question: str, cands: list[ScoredChunk]) -> RerankOutcome:        pairs = [(question, s.chunk.text) for s in cands]        ce_scores = map(float, self.ce.predict(pairs))        ranked = sorted(zip(cands, ce_scores), key=lambda p: -p[1])for s, rerank_score in ranked:            s.rerank_score = rerank_score                # 融合分留着,别覆盖return RerankOutcome([s for s, _ in ranked], True)

关键设计:重排的分数没法预先算好(输入里含着问题),每条候选都要把模型从头到尾跑一遍——36 块的小库无所谓,库到几千块,用户每问一句就是几千次模型计算,等不起。所以只对融合后前 30~50 条精挑。重排前后两个分数都留在日志里,方便对比"谁被捞上来、谁被压下去"。引擎有本地 CrossEncoder 和 LLM listwise 两种,谁快谁准,下面"跑起来"用同一套题实测过。

四段代码在 week10_rag.py 里,程序先 rewrite_query 拿到改写和变体,每个变体各跑 BM25 和向量两路;chunk_id 级 RRF 融合后截断 50 条,最后 rerank 取 Top-3。原问题、改写、各路排名、融合分、重排分,整个过程全程留痕,方便排查。

跑起来:四套方案对比实测

10 道口语化题,使用纯向量(基线)、查询改写、改写+混合检索、改写+混合+LLM 重排等四个方案各跑一遍。 其中 9 道是上周原题换了口语化说法,1 道是故意塞了三个子问题的复合题,全部列出:

  • "AI 聊着聊着就忘了前面说的话,咋办"(期望 06)
  • "让模型按固定格式吐数据,它总不听话"(02)
  • "用大模型一个月大概要花多少钱"(09)
  • "模型怎么自己决定该调哪个工具的"(03)
  • "内网上不了外网,还能不能用大模型"(10)
  • "两份文件里说法打架,AI 听谁的"(07)
  • "AI 干活停不下来怎么办"(04)
  • "网页上扒下来的内容能直接信吗"(05)
  • "temperature 拧到 0 是干嘛的"(01)
  • "怎么给 AI 外挂技能,要花钱吗,用什么模型跑"(复合题,主问题期望 03)

判分标准:每道题的标准答案文档,看它落在检索结果前几名。四个测量指标:Recall@3 看标准答案进没进前三(10 道里进了几道),Recall@5 放宽到前五,Top-1 看它是不是恰好排第一,MRR 是名次综合分(第一名记 1 分、第二名记 0.5、第三名记 0.33,全部题取平均)。简单来说Recall 管找到没有,Top-1 管一击即中,MRR 管平均排多前。

四个方案的成绩摆在一起,差别很清楚。

方案
Recall@3
Recall@5
Top-1
MRR
平均延迟
候选数
纯向量(基线)
0.80
0.90
0.70
0.75
2ms*
10
+查询改写
0.80
0.90
0.70
0.77
17.9s
17
改写+混合检索(BM25+RRF)
0.70
0.70
0.70
0.70
16.6s
25
改写+混合+LLM 重排
1.00
1.00
1.00
1.00
38.5s
25

纯向量基线跑下来,10 道题有 8 道把期望文档送进了前三,Recall@3 是 0.80。这个成绩理论上不应该这么好,原因还是我本地的资料库文档数量少,没有给它上难度。 

加上查询改写之后,命中的还是那 8 道题,一道也没有多出来:还是因为这是10 题的小库,口语题靠向量本来就能挤进前五,改写这身翻译功夫没用上劲,唯一的变化是期望文档的平均名次微微前移(MRR 从 0.75 到 0.77,名次综合分,细节看表)。 

混合检索这个场景下不但没帮忙,反而把命中数从 8 道拉到了 7 道:BM25 的 2-gram 把"咋办""多少钱"这类口语词切碎,凑巧命中了一堆文档的开头块,忘事和花多少钱两道题被这些噪声挤出了前五——在这个具体的语料上,2-gram 版 BM25 反而起了反作用。

 加上LLM重排之后,10 道题全部命中,期望文档全部排到了第一,四项指标全是 1.00。重排比的不是"像不像",是"能不能回答":忘事那道题,库里真正教你怎么办的那一节(《06》的"压缩不是万能药")融合完只在候选池排第 7,重排直接把它提到第 2;复合题在另外三个方案全军覆没,也只有重排把它救回了第一。但是满分的成绩是拿延迟买来的。LLM重排版平均一题要 38.5 秒,基线只要 2 毫秒;重排版的大头全在 GLM 调用上:改写调一次,重排再调一次。

重排引擎
Recall@3
Top-1
MRR
重排环节耗时
LLM listwise(GLM-5.3-Flash)
1.00
1.00
1.00
约 20s/题
本地 CrossEncoder
0.90
0.80
0.85
346ms/23 条

刚才那轮用的是 LLM 重排:把 25 条候选一起发给 GLM,让它从里面挑最能回答问题的。换上专做重排的小模型(bge-reranker-v2-m3,568M 参数,常驻 RTX 4060 显卡)再跑一轮,重排这一步从约 20 秒降到 346 毫秒,快了 57 倍;代价是命中从 10 道变 9 道,排第一的也少了一道,丢掉的恰好就是复合题——小模型逐对打分,掂不出复合题里哪个意图是主意图,而LLM 能把这道题救回来。

基于实验,我们得出选型结论:要速度快的场景(客服、问答助手),用 CrossEncoder,0.35 秒才能满足用户期望;要严谨且不着急的场景,才舍得用 LLM,多等 20 秒换那点准确率。

本次模型和关键配置:

  • 向量:Ollama bge-m3(本地);
  • 关键词:手写 BM25,字符 2-gram,k1=1.5、b=0.75
  • 改写:GLM-5.3-Flash(1 次/题);
  • 重排:LLM listwise(GLM)与本地 CrossEncoder(bge-reranker-v2-m3,RTX 4060)各实测一轮
  • RRF k=60;候选池上限 50(实测融合后 17~25 条);
  • 最终 Top-3

样本局限:本轮只跑了 10 道口语化题,原题上周测过;题是我自己出题自己标期望文档,口语化的"地道程度"存疑;判分仍是文档级,块级好坏看不见;基线延迟是缓存后的纯比对、其余是 GLM 调用主导,两组数字口径不同,看量级别抠精确值。

4. 我踩过的坑

(以下几条全是本周真实踩到的,按翻车顺序排列。)

现象
错误原因
解决办法
本地向量器读 bge-m3 建的索引,报"向量维度不一致:4096 vs 1024"
索引和向量器不配套:bge-m3 是 1024 维,本地 TF-IDF 哈希是 4096 维,两边根本不在一个向量空间
索引跟着向量器走(--embedding ollama 配套 bge-m3 索引)。上篇"换模型要重建"的孪生坑
混合检索比纯向量还掉分,两道口语题被拖出 Top-5
BM25 的 2-gram 把"咋办""多少钱"切进了所有文档的开头块,关键词路输出的全是噪声
中文 2-gram 是权宜;上 jieba 加术语词典,或干脆给 BM25 路降权
LLM 重排一题 22 秒,10 题跑了快 4 分钟
重排提示词塞 25 条候选 × 200 字,模型要全部读完才能排序
候选截到 15~20 条、每条截 150 字;或换本地 CrossEncoder
模型加载成功,打分却恒为 1.777,换什么文本都一个分
tokenizer 四件小文件丢了(拉模型半途中断),只校验了大权重文件的哈希;分词器静默退化,所有字变成,模型看的全是乱码
分件核对尺寸,别只盯最大的权重;打分恒定先查 input_ids 是不是全 unk

5. 当前代码存在的问题与适用范围

已知问题清单:

问题
原因
状态
问题拆分的串行模式没做(前一步答案喂后一步)
属于 Agentic RAG 范畴
后续 Agent 阶段处理
BM25 没上分词和术语词典,2-gram 是权宜
中文分词要引 jieba,术语要人工维护
实测 2-gram 版掉分,上词典或降权,评测篇量化后定
Rerank 引入 torch,依赖重
模型 2G+,部署门槛抬高
已实测(346ms/23 条);LLM 版质量更高但约 20s/题,按场景选
拒答阈值还没在新分数上重校准
阈值挂载点从余弦分挪到重排分
本轮 compare 不经阈值闸门,上线前校

适用范围:

  • 用户问口语、文档写得书面:政务和企业内部知识库多是这个形态
  • 问题里带错误码、型号、编号、专有名词:BM25 的主场
  • 同一件事用户说法五花八门:多查询扩展的用武之地

会翻车的情况:

  • 私有冷门领域:改写和 HyDE 都靠 LLM 的常识,常识没有就编,编错方向比不改写还糟,必须先配术语词典再谈改写
  • 用户在线等答案的场景:Cross-Encoder 的耗时跟候选池大小成正比,实测 23 条 346 毫秒,池子翻十倍延迟也跟着翻十倍——对话要秒回,候选池就必须压在几十条以内
  • 改写失败回退原问题后还是搜不到:多半是库里真没有,这时该走拒答,不是回去再改写一轮。“没搜到”有两种,一种改写能救,一种只有拒答能救

6. 给读者的提问

留四道我学习时答错或卡壳的原题,答案都在正文里:

  1. 两路检索的结果要合并,为什么不能把两边的分数直接相加?RRF 拿什么代替了分数?(提示:想想两路分数的取值范围差在哪)
  2. 向量检索能把文档离线编码好存起来,Cross-Encoder 为什么必须等用户来了现算?它俩架构上差在哪?
  3. HyDE 拿编出来的假答案去检索,为什么反而比拿原问题去搜更准?什么样的库不能用它?
  4. 你的用户提问偏口语还是偏术语?你的知识库里哪类问题最容易漏召回?评论区聊聊。

7. 结尾

上周埋的雷这次拆掉了:先把口语问题翻译成文档中的用语,再使用关键词和向量两路一起查询召回,最后使用重排把最相关的证据挑到最前面。通过这个工作流把检索从“一次向量查询定生死”变成一条每步留痕的流水线。但新问题跟着冒头:Recall@K 高了就等于答案好吗?检索的锅和生成的锅怎么分?评测集、指标、分层归因,下一篇《如何判断一个 RAG 系统到底好不好?》我们继续说。

如果你也想跟着跑一遍,请关注我并私信我,我会把我的代码共享给你

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料