很多 RAG 不好,不是生成差,而是前面的找和排都没做好
你可能遇到过这种情况:RAG 搭好了,demo 演示一切正常,结果一上线,用户问"ERR_2048 怎么处理",系统却把 ERR_2049 的文档翻出来一本正经地讲;用户问个稍微绕一点的问题,答案干脆开始编。第一反应往往是"模型不行,换个更大的"。
但十有八九,问题不在生成。RAG 是"检索 + 生成",生成只是最后一棒。如果前面的"找"和"排"就漏了、错了,再强的模型也只是在错误的上下文上把胡话说得更流畅。这一篇我们就顺着链路,一层层看 RAG 到底在哪儿掉链子。
纯向量检索的天花板:它认不准你的专有名词
绝大多数 RAG 教程教的是纯向量检索:query 转成向量,去库里找最近的几个。它的强项是语义近似——你问"怎么让程序跑得更快",它能找到标题是"性能优化"的文档,哪怕一个字都没重合。
但它有个结构性的弱点:对精确匹配很不灵。向量是稠密的、是"语义涂抹"过的,ERR_2048 和 ERR_2049 在向量空间里几乎贴在一起,产品型号 X100 和 X200、缩写、人名、API 名也一样。可偏偏这些专有名词,往往是用户查询里信息量最高、最不能错的词。语义检索会告诉你"这两个挺像",而用户要的是"就那一个"。
这就是纯向量检索的天花板:它做相似,但不做精确;它擅长长尾语义,却对训练里少见的稀有词、新词无能为力。指望靠换更大的 embedding 模型来补这个洞,方向就错了。
混合检索:让关键词和语义各干各擅长的
补这个洞的办法,是把另一种检索请回来——稀疏检索,代表就是 BM25。它是 TF-IDF 的改进版,本质是基于词频的关键词匹配算法,在向量检索流行之前撑了搜索引擎几十年。它的特点恰好和向量检索互补:精确词命中极强,ERR_2048 就是 ERR_2048,但完全不懂语义,你问"快"它找不到"性能"。
所以混合检索的思路就是两路一起跑:BM25 负责关键词精确召回,向量负责语义召回,最后把两份结果合并。难点在"怎么合并"——BM25 给出的分数和余弦相似度根本不在一个量纲上,直接相加是没有意义的。
业界最常用的解法是 RRF(Reciprocal Rank Fusion,倒数排名融合)。它聪明在不看分数,只看排名:一个文档在某一路里排第几,就贡献 1/(k + rank) 的分数,两路加起来排序。这样就绕开了分数不可比的问题。
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
"""rankings: 多路检索结果,每路是按相关性排好的 doc_id 列表"""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
vector_hits = vector_search(query, top_k=30) # 语义召回
bm25_hits = bm25_search(query, top_k=30) # 关键词召回
fused = reciprocal_rank_fusion([vector_hits, bm25_hits])
那个常数 k(经验值 60)的作用是压平头部——让排第 1 和排第 2 的差距不至于过分悬殊,避免某一路的榜首一家独大。光是把纯向量换成混合检索,很多 RAG 的召回就能明显回血。
Rerank:召回要快要广,精排才负责准
混合检索解决了"找得全",但还有个"排得准"的问题。
这里要理解一个架构上的取舍。检索阶段用的是 bi-encoder——query 和文档各自独立编码成向量,再算距离。它的好处是文档向量可以预先算好、建索引,查询时极快,能在百万文档里瞬间召回。但代价是 query 和文档在编码时从未"见过"对方,没有交互,相关性判断就比较粗。
于是有了 reranker,通常是 cross-encoder:它把 query 和某个文档拼在一起整个送进模型,让两者在每一层做 token 级的交互,最后吐一个精细的相关性分数。它判断得准得多,但慢得多——慢到你不可能拿它去扫整个库。
答案就是两阶段:用快而广的混合检索召回 top 50 到 100 个候选,再用慢而准的 reranker 把这批候选精排,留下 top 3 到 5 个喂给模型。reranker 只面对几十个候选,慢一点也扛得住。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def retrieve(query: str, final_k: int = 5) -> list[str]:
candidates = reciprocal_rank_fusion([ # 第一阶段:快、广
vector_search(query, top_k=50),
bm25_search(query, top_k=50),
])[:80]
pairs = [(query, doc_text(c)) for c in candidates]
scores = reranker.predict(pairs) # 第二阶段:慢、准
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, _ in ranked[:final_k]]
reranker 可以用 Cohere Rerank 这类 API,也可以本地跑 bge-reranker、jina-reranker。经验上,加 rerank 这一步的性价比极高,常常是单点投入里对 RAG 质量提升最大的一个改动。
用户问的,不一定是他想问的
到这里检索链路已经不错了,但还有个前提一直没动:我们一直假设"用户的提问"可以直接拿去检索。现实里它经常不能。
用户的问题是口语化的、带指代的、缺上下文的。多轮对话里更明显——用户上一句问"LangGraph 怎么装",下一句只说"那它和 LangChain 啥关系",这个"它"直接拿去检索,向量库根本不知道在问谁。所以对话式 RAG 必须先做指代消解,把历史轮次压进一个能独立成立的 query,这步叫 query 改写或 query contextualization。
更进一步有个很妙的技巧叫 HyDE(Hypothetical Document Embeddings,假设性文档嵌入)。它的洞察是:你库里存的是"文档",用户给的是"问题",而问题和文档在语义空间里其实长得不太像——问题短、疑问语气,文档长、陈述语气。与其拿问题去找文档,不如先让 LLM 根据问题瞎编一个"假想答案文档",再用这个假文档的向量去检索。假文档和库里真实文档同为陈述性的长文本,语义形态对得上,命中率反而更高。关键是:哪怕这个假答案在事实上是错的也没关系——你要的只是它的语义方向,最终用于生成的还是检索回来的真实文档。
def hyde_retrieve(query: str) -> list[str]:
# 让模型先编一段假想答案,不追求正确,只要语义形态像真实文档
hypothetical = llm(f"针对下面的问题,写一段简短的、像技术文档的回答:\n{query}")
# 用假文档的向量去检索,比用问题本身更贴近库里的真实文档
return vector_search_by_text(hypothetical, top_k=30)
还有一个思路是多查询:让 LLM 把一个问题改写出三四个角度不同的版本,分别检索再用 RRF 合并,能显著降低"一个 query 措辞不好就全盘漏掉"的风险。对于需要多步推理的复杂问题,则可以用 step-back——先退一步检索更宏观的背景知识,再检索具体细节。
RAG 到底好不好,得分层量出来
最后,怎么判断这些改动是真有用还是自我感觉良好。和 Prompt 一样,RAG 也不能靠"感觉",要量化,而且要分层量——因为 RAG 出问题,可能在检索,也可能在生成,混在一起看你永远定位不到。
检索这一层,看的是召回类指标:Recall@k(该被找到的相关文档,有没有进 top-k)、MRR、NDCG、Hit Rate。生成这一层,业界常用 RAGAS 这套框架的几个指标:Faithfulness(忠实度)——答案里的每句话是不是都能在检索到的上下文里找到支撑,这一项低就是在编;Answer Relevance(答案相关性)——答案切不切题;以及 Context Precision / Recall——检索给到的上下文,有用的比例高不高、该给的给全没有。
分层的价值在于定位。如果 Recall 低,问题在检索,去查混合检索和分块;如果 Recall 不低、但 Faithfulness 低,说明文档找到了、模型却没好好用甚至在编,问题在生成的 Prompt 或上下文组织。没有现成评估集也不要紧——可以先让 LLM 基于你的文档库批量合成一批"问题-答案-出处"三元组,作为冷启动的评估集。
把链路拆成找、排、改写、生成四段,每段都有指标盯着,你的 RAG 优化就从"换个模型碰碰运气"变成了"哪段漏了补哪段"。
检索这条线到这里就比较完整了。但 RAG 本质还是"把资料塞给模型让它读",模型是被动的。下一篇我们让模型主动起来——它不再只是读你给的上下文,而是自己决定去调哪个工具、拿什么数据。Function Calling,才是模型从"会说"走向"会做"的第一步。
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:你的 RAG 为什么回答得不好
本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/04-你的RAG为什么回答得不好/
本文最后一次更新为 天前,文章中的某些内容可能已过时!