RAG 系列(十五):CRAG——检索结果不好时自动纠偏

0 阅读8分钟

知识库的边界问题

前面几篇优化了检索质量:更好的分块、更精准的排序、更聪明的问法。但有一个根本性的问题一直被回避:

如果知识库本来就没有这个问题的答案呢?

用户问的问题超出了知识库的覆盖范围,向量检索依然会返回"最相似"的几篇文档——但这些文档和问题的真实答案毫无关系。LLM 拿到这些文档,要么生成一个基于无关内容的幻觉答案,要么说"根据参考资料无法回答"——两种结果都不理想。

这是传统 RAG 的盲点:它从不质疑检索结果的质量,只是无条件地把文档塞给 LLM。

2024 年提出的 CRAG(Corrective RAG) 在这里加了一个"纠偏"步骤:先评估检索结果的质量,不合格时主动触发网络搜索作为补充或替代,而不是将就着用低质量的文档生成答案。


CRAG 的核心流程

用户问题
    ↓
向量检索(知识库)
    ↓
相关性评分:对每篇文档打 0~1 的相关性分数
    ↓
综合判断(三种 verdict)
    ├─ CORRECT(≥0.7)  → 直接使用知识库文档
    ├─ INCORRECT(≤0.3)→ 丢弃知识库,触发网络搜索
    └─ AMBIGUOUS(中间) → 知识库文档 + 网络搜索结果合并
    ↓
(网络搜索结果经 LLM 精炼,提取关键信息)
    ↓
基于最终文档生成答案

和 Self-RAG 的区别在于:

  • Self-RAG 解决"要不要检索"——在查询前决策
  • CRAG 解决"检索结果够不够好"——在检索后评估,不好就纠偏

用 LangGraph 实现

State 设计

class CRAGState(TypedDict):
    question: str
    retrieved_docs: list[Document]
    doc_scores: list[float]          # 每篇文档的相关性分数
    overall_score: float             # 综合评分
    retrieval_verdict: str           # "correct" | "ambiguous" | "incorrect"
    web_results: str                 # 网络搜索原始结果
    refined_web_docs: list[Document] # LLM 精炼后的网络文档
    final_docs: list[Document]       # 最终送入 LLM 的文档
    answer: str
    path: list[str]

关键节点:相关性评分(score)

这是 CRAG 的核心节点——对每篇检索文档逐一打分:

RELEVANCE_SCORE_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "给出一个 0.0 到 1.0 之间的分数,表示文档与问题的相关程度。\n"
     "- 1.0:文档直接、完整地回答了问题\n"
     "- 0.5:文档部分相关,但不完整\n"
     "- 0.0:文档与问题完全无关\n\n"
     "只输出一个浮点数,不要任何解释。"),
    ("human", "问题:{question}\n\n文档:{document}"),
])

def make_score_node(llm):
    chain = RELEVANCE_SCORE_PROMPT | llm | StrOutputParser()

    def score_docs(state):
        scores = []
        for doc in state["retrieved_docs"]:
            raw = chain.invoke({
                "question": state["question"],
                "document": doc.page_content[:400],
            })
            score = float(raw.strip())
            scores.append(max(0.0, min(1.0, score)))

        overall = sum(scores) / len(scores)

        if overall >= 0.7:
            verdict = "correct"
        elif overall <= 0.3:
            verdict = "incorrect"
        else:
            verdict = "ambiguous"

        return {**state, "doc_scores": scores, "overall_score": overall,
                "retrieval_verdict": verdict}

    return score_docs

关键节点:网络搜索 + 精炼(web_search)

搜索结果往往是噪声,直接喂给 LLM 效果差。CRAG 在这里加了一步 LLM 精炼:

REFINE_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "从以下网络搜索结果中,提取与问题最相关的关键信息,"
     "整理成简洁的参考资料。去除无关内容,保留核心事实。"),
    ("human", "问题:{question}\n\n搜索结果:\n{search_results}\n\n请提取关键信息:"),
])

def make_web_search_node(search_tool, llm):
    refine_chain = REFINE_PROMPT | llm | StrOutputParser()

    def web_search(state):
        try:
            raw_results = search_tool.invoke(state["question"])
            refined = refine_chain.invoke({
                "question": state["question"],
                "search_results": raw_results[:2000],
            })
            web_doc = Document(page_content=refined,
                               metadata={"source": "web_search"})
            return {**state, "refined_web_docs": [web_doc]}
        except Exception:
            # 网络不可用时回退到知识库文档
            return {**state, "refined_web_docs": []}

    return web_search

关键节点:文档组装(assemble)

根据 verdict 决定最终使用哪些文档:

def make_assemble_node():
    def assemble(state):
        verdict = state["retrieval_verdict"]

        if verdict == "correct":
            # 只用知识库高分文档
            scored = sorted(zip(state["retrieved_docs"], state["doc_scores"]),
                           key=lambda x: x[1], reverse=True)
            final = [doc for doc, score in scored if score >= 0.3] or [scored[0][0]]

        elif verdict == "incorrect":
            # 优先用网络搜索;不可用时回退到知识库最优文档
            final = state.get("refined_web_docs", [])
            if not final:
                scored = sorted(zip(state["retrieved_docs"], state["doc_scores"]),
                               key=lambda x: x[1], reverse=True)
                final = [scored[0][0]]

        else:  # ambiguous
            # 知识库高分文档 + 网络搜索结果合并
            scored = zip(state["retrieved_docs"], state["doc_scores"])
            kb_docs = [doc for doc, score in scored if score >= 0.3]
            final = kb_docs + state.get("refined_web_docs", [])

        return {**state, "final_docs": final}

    return assemble

Graph 结构

graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "score")
graph.add_conditional_edges(
    "score",
    lambda s: "web_search" if s["retrieval_verdict"] != "correct" else "assemble",
    {"web_search": "web_search", "assemble": "assemble"},
)
graph.add_edge("web_search", "assemble")
graph.add_edge("assemble",   "generate")
graph.add_edge("generate",   END)

实验结果

执行路径明细

CRAG 执行路径明细:

Q1: retrieve → score(ambiguous, 0.62) → web_search(ok) → assemble(5docs) → generate
    "什么是 RAG 技术,它主要解决什么问题?"

Q2: retrieve → score(incorrect, 0.12) → web_search(ok) → assemble(1docs) → generate
    "企业级应用应该选择哪种向量数据库?"

Q3: retrieve → score(ambiguous, 0.45) → web_search(ok) → assemble(4docs) → generate
    "中文场景应该选择哪个 Embedding 模型?"

Q4: retrieve → score(incorrect, 0.20) → web_search(ok) → assemble(1docs) → generate
    "文档分块时 Chunk Size 一般推荐多少?"

Q5: retrieve → score(ambiguous, 0.38) → web_search(ok) → assemble(3docs) → generate
    "RAGAS 框架包含哪四个核心评估指标?"

Q6: retrieve → score(incorrect, 0.00) → web_search(ok) → assemble(1docs) → generate
    "RRF 融合算法的公式是什么?"

Q7: retrieve → score(incorrect, 0.17) → web_search(ok) → assemble(1docs) → generate
    "HyDE 查询优化技术的原理是什么?"

Q8: retrieve → score(ambiguous, 0.33) → web_search(ok) → assemble(3docs) → generate
    "生产级 RAG 系统如何实现多租户隔离?"

相关性评分分布:correct=0,ambiguous=4,incorrect=4

8 条问题中,没有一条被评为 correct:4 条 ambiguous,4 条 incorrect。说明评分模型对"知识库文档能否直接回答问题"的判断相当严格。

注意 Q6(RRF 融合算法的公式,分数 0.00)和 Q7(HyDE 的原理,分数 0.17)——这两个是前面几篇文章才讲到的新概念,知识库里确实没有对应内容,评分器正确识别并触发了网络搜索。

RAGAS 指标对比

======================================================================
  RAGAS 指标对比(固定检索 vs CRAG)
======================================================================

  指标                    固定检索      CRAG        变化
  ──────────────────────────────────────────────────────
  context_recall          0.625        0.625     →+0.000
  context_precision       0.444        0.875     ↑+0.431  ◀
  faithfulness            0.810        0.907     ↑+0.097
  answer_relevancy        0.402        0.368     ↓-0.033
======================================================================

context_precision +0.431,这是本系列所有实验中单项提升最大的一次。

为什么提升这么大?

context_precision 衡量的是:相关文档是否排在不相关文档前面

固定检索把所有 top-4 文档平等地塞给 LLM,分数高低不论。CRAG 的 score 节点给每篇文档打了精确的相关性分,assemble 节点按分数过滤和排序,最终送给 LLM 的文档集合更干净、更有针对性。

更关键的是:对 incorrect 的问题,CRAG 完全放弃了低质量的知识库文档,改用网络搜索的精炼结果。这些结果直接命中问题,context_precision 自然极高。

固定检索的 context_precision 只有 0.444(连 0.5 都不到),意味着许多问题里,相关文档甚至被排在了不相关文档后面。CRAG 的评分+过滤机制彻底扭转了这个局面。


与 Self-RAG 的核心差异

维度Self-RAGCRAG
决策时机检索前(要不要检索?)检索后(结果够不够好?)
核心问题避免不必要的检索低质量检索结果的纠偏
兜底机制直接生成(无检索)网络搜索(外部知识源)
适用场景混合型对话,部分问题不需要检索知识库覆盖有限,问题可能超出范围
关键节点decide → routescore → assemble

两者不互斥,可以组合:Self-RAG 先判断要不要检索,检索后 CRAG 再判断结果够不够好。


适用场景与注意事项

CRAG 最适合的场景:

  • 知识库覆盖有限,用户问题可能超出范围(本实验典型案例)
  • 知识库内容更新较慢,部分问题需要最新的网络信息
  • 对答案质量要求高,宁可触发网络搜索也不将就

需要注意的问题:

  • 评分模型的校准:评分阈值(0.7 / 0.3)需要根据具体知识库调整。本实验阈值偏严,导致 0 条 correct,实际部署时可能需要放宽
  • 网络搜索的可靠性:网络搜索结果质量参差不齐,精炼步骤很重要;网络不可用时需要优雅降级
  • 成本上升:每次查询多出评分(4 次 LLM 调用)+ 可能的网络搜索 + 精炼,token 消耗显著增加

完整代码

代码已开源:

github.com/chendongqi/…

核心文件:

  • crag.py — LangGraph 实现的完整 CRAG 流程

运行方式:

git clone https://github.com/chendongqi/llm-in-action
cd 15-crag
cp .env.example .env
pip install -r requirements.txt
python crag.py

小结

本文用 LangGraph 实现了 CRAG,核心发现:

  1. 评分驱动的文档过滤是 context_precision 大幅提升(+0.431)的根本原因——给文档打分、过滤、按分排序,远比盲目使用 top-k 更有效
  2. 网络搜索 fallback 解决了知识库覆盖盲区,让系统在不熟悉的话题上也能给出有据可查的答案
  3. CRAG 与 Self-RAG 互补:前者解决"要不要检索",后者解决"检索结果够不够好"

从实验数据来看,评分+过滤这个机制本身就值得单独引入——即便不接入网络搜索,只是把 CRAG 的 score 节点嫁接到普通 RAG 流程上,context_precision 就会有显著提升。


参考资料