很多 RAG 不好,不是生成差,而是前面的找和排都没做好

RAG 答不好,问题常常不在生成

RAG 搭好之后,常见的失望是这样的:demo 演示一切正常,一上线就开始出岔子。用户问 ERR_2048 怎么处理,系统把 ERR_2049 的文档翻出来一本正经地讲;用户问个稍微绕一点的问题,答案干脆开始编。

遇到这种情况,第一反应往往是"模型不行,换个更大的"。但多数时候问题不在生成。RAG 是检索加生成,生成只是最后一棒。如果前面的"找"和"排"就漏了、错了,再强的模型也只是在错误的上下文上把话说得更流畅。这一篇顺着检索链路,逐层看 RAG 在哪里掉链子。

纯向量检索的天花板

多数 RAG 教程教的是纯向量检索:查询转成向量,去库里找最近的几个。它的强项是语义近似——问"怎么让程序跑得更快",能找到标题是"性能优化"的文档,哪怕一个字都不重合。

但它有个结构性弱点:对精确匹配不灵。向量是稠密的、经过语义"涂抹"的,ERR_2048ERR_2049 在向量空间里几乎贴在一起,产品型号 X100X200、人名、缩写、API 名也是同理。可偏偏这些专有名词,往往是用户查询里信息量最高、最不能错的词。语义检索会告诉你"这两个挺像",而用户要的是"就那一个"。

这就是纯向量检索的天花板:它做相似,不做精确;它擅长长尾语义,却对训练里少见的稀有词、新词无能为力。指望换个更大的 embedding 模型来补这个洞,方向就错了。

混合检索:向量加 BM25

补这个洞的办法,是把另一种检索请回来——稀疏检索,代表是 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)

常数 k(经验值 60)的作用是压平头部,让排第 1 和第 2 的差距不至于过分悬殊,避免某一路的榜首一家独大。仅仅把纯向量换成混合检索,很多 RAG 的召回就能明显回血。

Rerank:召回要快要广,精排才负责准

混合检索解决了"找得全",但还有个"排得准"的问题。

这里要理解一个架构上的取舍。检索阶段用的是 bi-encoder——查询和文档各自独立编码成向量,再算距离。好处是文档向量可以预先算好、建索引,查询时极快,能在百万文档里瞬间召回。代价是查询和文档编码时从未"见过"对方,没有交互,相关性判断比较粗。

于是有了 reranker,通常是 cross-encoder:它把查询和某个文档拼在一起整个送进模型,让两者在每一层做 Token 级交互,最后输出一个精细的相关性分数。它判断得准得多,但慢得多,慢到不可能拿它去扫整个库。

答案是两阶段:用快而广的混合检索召回 top 50 到 100 个候选,再用慢而准的 reranker 把这批候选精排,留下 top 3 到 5 个交给模型。reranker 只面对几十个候选,慢一点也扛得住。

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")


def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
    """candidates: [{"id": ..., "text": ...}, ...],用 cross-encoder 精排"""
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [{**c, "rerank_score": float(s)} for c, s in ranked[:top_k]]

reranker 可以用 Cohere Rerank 这类 API,也可以本地跑 bge-reranker、jina-reranker。经验上,加 rerank 这一步的性价比极高,常常是单点投入里对 RAG 质量提升最大的改动。

查询改写与 HyDE

到这里检索链路已经不错了,但还有个前提一直没动:我们默认"用户的提问"可以直接拿去检索。现实里它经常不能。

用户的问题是口语化的、带指代的、缺上下文的。多轮对话里更明显——用户上一句问"LangGraph 怎么装",下一句只说"那它和 LangChain 啥关系",这个"它"直接拿去检索,向量库根本不知道在问谁。所以对话式 RAG 必须先做指代消解,把历史轮次压进一个能独立成立的查询,这步叫查询改写。

更进一步有个技巧叫 HyDE(Hypothetical Document Embeddings,假设性文档嵌入)。它的洞察是:库里存的是"文档",用户给的是"问题",而问题和文档在语义空间里其实长得不太像——问题短、是疑问语气,文档长、是陈述语气。与其拿问题去找文档,不如先让模型根据问题生成一个"假想答案文档",再用这个假文档的向量去检索。假文档和库里真实文档同为陈述性长文本,语义形态对得上,命中率反而更高。关键是,哪怕这个假答案在事实上是错的也没关系——要的只是它的语义方向,最终用于生成的还是检索回来的真实文档。

from openai import OpenAI

client = OpenAI(api_key="...", base_url="https://api.deepseek.com/v1")


def hyde_query(question: str) -> str:
    """让模型先生成一段假想答案,用它替代原始问题去检索"""
    resp = client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user",
                   "content": f"针对下面的问题,写一段简短的、像技术文档的回答,"
                              f"不必保证正确:\n{question}"}],
        temperature=0.3,
    )
    return resp.choices[0].message.content

还有一个思路是多查询:让模型把一个问题改写成三四个角度不同的版本,分别检索再用 RRF 合并,能显著降低"一个查询措辞不好就整体漏掉"的风险。对需要多步推理的复杂问题,则可以用 step-back——先退一步检索更宏观的背景知识,再检索具体细节。

分层评估:找、排、写各看各的指标

最后是怎么判断这些改动是真有用还是自我感觉良好。和 Prompt 一样,RAG 也不能靠感觉,而且要分层评估——因为 RAG 出问题可能在检索,也可能在生成,混在一起看永远定位不到。

检索这一层看召回类指标:Recall@k(该被找到的相关文档有没有进 top-k)、MRR、NDCG、Hit Rate。生成这一层,业界常用 RAGAS 框架的几个指标:Faithfulness(忠实度,答案的每句话是不是都能在检索到的上下文里找到支撑,这一项低就是在编)、Answer Relevance(答案相关性,答案切不切题)、Context Precision / Recall(检索到的上下文有用比例高不高、该给的给全没有)。

分层的价值在定位。Recall 低,问题在检索,去查混合检索和分块;Recall 不低但 Faithfulness 低,说明文档找到了、模型却没好好用甚至在编,问题在生成的 Prompt 或上下文组织。没有现成评估集也不要紧——可以先让模型基于文档库批量合成一批"问题-答案-出处"三元组,作为冷启动的评估集。

完整示例:混合检索加重排序的检索器

把混合检索、RRF、rerank 整合成一个检索器类。它对外暴露一个 retrieve(),内部依次走召回、融合、精排:

# hybrid_retriever.py
from sentence_transformers import CrossEncoder

# vector_search / bm25_search 假设已实现,各返回按相关性排序的 doc_id 列表
# doc_store 是 doc_id -> {"text": ..., "source": ...} 的映射


class HybridRetriever:
    def __init__(self, doc_store: dict):
        self.doc_store = doc_store
        self.reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

    @staticmethod
    def _rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
        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)

    def retrieve(self, query: str, final_k: int = 5) -> list[dict]:
        # 第一阶段:混合检索召回,要快、要广
        fused = self._rrf([
            vector_search(query, top_k=50),
            bm25_search(query, top_k=50),
        ])[:80]

        # 第二阶段:cross-encoder 精排,要准
        candidates = [self.doc_store[i] for i in fused if i in self.doc_store]
        pairs = [(query, c["text"]) for c in candidates]
        scores = self.reranker.predict(pairs)
        ranked = sorted(zip(candidates, scores),
                        key=lambda x: x[1], reverse=True)
        return [{**c, "score": float(s)} for c, s in ranked[:final_k]]


if __name__ == "__main__":
    retriever = HybridRetriever(doc_store=load_doc_store())
    for hit in retriever.retrieve("ERR_2048 报错怎么处理"):
        print(f"[{hit['score']:.3f}] {hit['source']}: {hit['text'][:60]}")

这个检索器把"召回快而广、精排准"这条原则固化进了结构。要接入 HyDE 或多查询,只需在 retrieve() 入口处替换或扩展查询;要换 reranker,只改一个字段。

几个常见踩坑

只用纯向量检索。它对错误码、型号这类专有名词不灵。要叠加 BM25 做混合检索。

直接相加不同检索路的分数。BM25 分数和余弦相似度量纲不同,相加无意义。用 RRF 按排名融合。

跳过 rerank。bi-encoder 召回粗,cross-encoder 精排是性价比最高的提升点之一。

直接拿用户原话去检索。口语化、带指代的问题对检索不友好,要先做查询改写,多轮场景要做指代消解。

RAG 效果不分层评估。检索和生成混在一起看,定位不到问题。检索看 Recall,生成看 Faithfulness。

Faithfulness 低就去换更大的模型。答案在编往往是上下文没找对,先查检索环节,而不是先换模型。

本篇要点

  • 纯向量检索擅长语义近似,但对专有名词、错误码这类精确匹配有天花板
  • 混合检索用 BM25 补精确匹配,用 RRF 按排名融合,绕开分数量纲不可比的问题
  • 检索用 bi-encoder 求快求广,rerank 用 cross-encoder 求准,两阶段配合
  • 用户原始问题往往不适合直接检索,需要查询改写;HyDE 用假想答案文档提升命中
  • RAG 要分层评估:检索看 Recall/MRR,生成看 Faithfulness/Answer Relevance
  • 把混合检索与重排序整合成一个检索器,原则固化进结构,换组件不影响调用方

下一篇

检索这条线到这里比较完整了。但 RAG 本质还是"把资料塞给模型让它读",模型是被动的。下一篇让模型主动起来——它不再只读你给的上下文,而是自己决定调哪个工具、取什么数据。Function Calling 是模型从"会说"走向"会做"的第一步。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:你的 RAG 为什么回答得不好

本文链接:https://www.sshipanoo.com/blog/ai/llm-advanced/04-你的RAG为什么回答得不好/

本文最后一次更新为 天前,文章中的某些内容可能已过时!