Skip to content

自适应 RAG

上一篇 LangGraph 构建 RAG 走通了最基础的 retrieve → generate,但它有个致命假设:每个问题都该检索、检索结果一定相关、生成结果一定对。现实往往不是这样。本篇引入 Self-RAG 的思想,让模型自己判断"要不要检索""检索的相关吗""回答有没有幻觉",并用条件边把判断结果接到不同的分支上。

一、为什么需要"自适应"

基础 RAG 在这些场景会翻车:

  1. 闲聊问题也去检索:用户说"你好",模型还去向量库捞一堆文档,浪费 token 还可能把回答带偏。
  2. 检索到不相关文档:硬塞进 prompt 让模型"强行回答",反而制造幻觉。
  3. 回答与检索文档脱节:模型脑补了文档里没有的内容,没人检查。

自适应 RAG(受 Self-RAG 论文启发)的思路是:在关键节点上加评估器,由 LLM 自己打分,再用条件边根据分数路由。这正好是 LangGraph 条件边的用武之地。

对 Java/LangGraph4j 同学:这相当于在工作流里加 if/else 分支节点,每个分支是一个独立的处理路径。

二、整体流程图

自适应 RAG 在基础 RAG 上加了三个评估环节:

mermaid
flowchart TD
    S([START]) --> Q[query_analyze 查询分析]
    Q -- 需检索 --> R[retrieve 检索]
    Q -- 无需检索 --> G2[generate_direct 直接回答]
    R --> D[grade_docs 文档评分]
    D -- 有相关文档 --> G1[generate 生成]
    D -- 都不相关 --> RQ[rewrite_query 改写问题]
    RQ --> R
    G1 --> H[grade_answer 回答评分]
    H -- 合格 --> E([END])
    H -- 不合格/幻觉 --> G1
    G2 --> E

三个评估点:

评估点判断什么结果分流
query_analyze这个问题需要检索吗需检索 / 直接回答
grade_docs检索到的文档相关吗有相关 / 全不相关(改写后重检索)
grade_answer回答是否基于文档(无幻觉)合格 / 重生成

三、状态设计

需要在基础 RAG 状态上多加几个字段:原始问题、改写后的问题、文档相关性标记、回答是否合格。

python
from typing import TypedDict, List
from langchain_core.documents import Document

class AdaptiveRAGState(TypedDict):
    question: str               # 当前使用的问题(可能被改写)
    original_question: str      # 原始问题,便于追溯
    documents: List[Document]   # 检索到的文档
    need_retrieval: bool        # 是否需要检索
    relevant_docs: List[Document]  # 评分后筛出的相关文档
    answer: str                 # 最终回答
    retry_count: int            # 重生成次数,防止死循环

注意 retry_count 这个字段——任何带循环的图都必须有"刹车",否则一旦评分一直不合格就会无限重生成。

四、节点实现

1. 查询分析节点

让 LLM 判断问题是否需要查资料。用一个结构化输出会更稳。

python
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI

class QueryDecision(BaseModel):
    need_retrieval: bool = Field(description="是否需要检索知识库")

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# with_structured_output 让模型直接返回结构化对象,省去解析 JSON 的麻烦
decision_llm = llm.with_structured_output(QueryDecision)

def query_analyze(state: AdaptiveRAGState) -> dict:
    """判断问题是否需要检索"""
    question = state["question"]
    prompt = (
        "判断下面这个问题是否需要查询知识库才能回答。\n"
        "事实性、知识性问题需要检索;闲聊、问候、写作类不需要。\n\n"
        f"问题:{question}"
    )
    decision = decision_llm.invoke(prompt)
    return {"need_retrieval": decision.need_retrieval}

2. 文档评分节点

对每篇检索文档打"相关/不相关"分,只保留相关的。

python
class DocGrade(BaseModel):
    relevant: bool = Field(description="文档是否与问题相关")

grade_llm = llm.with_structured_output(DocGrade)

def grade_docs(state: AdaptiveRAGState) -> dict:
    """逐条给文档打相关性分"""
    question = state["question"]
    docs = state["documents"]
    relevant = []
    for d in docs:
        prompt = (
            "判断下面这段资料是否与问题相关,能帮助回答问题。\n\n"
            f"问题:{question}\n\n资料:{d.page_content}"
        )
        result = grade_llm.invoke(prompt)
        if result.relevant:
            relevant.append(d)
    return {"relevant_docs": relevant}

3. 问题改写节点

当文档全不相关时,把问题改写得更"好检索"。

python
def rewrite_query(state: AdaptiveRAGState) -> dict:
    """改写问题,使其更利于检索"""
    question = state["question"]
    prompt = (
        "把下面问题改写得更具体、更利于向量检索,只输出改写后的问题:\n"
        f"{question}"
    )
    new_q = llm.invoke(prompt).content
    return {"question": new_q, "retry_count": state.get("retry_count", 0) + 1}

4. 生成与回答评分

python
from langchain_core.prompts import ChatPromptTemplate

gen_prompt = ChatPromptTemplate.from_template(
    "只根据资料回答,资料不足就说不知道。\n资料:\n{context}\n\n问题:{question}\n回答:"
)

def generate(state: AdaptiveRAGState) -> dict:
    docs = state["relevant_docs"]
    context = "\n\n".join(d.page_content for d in docs)
    resp = llm.invoke(gen_prompt.invoke({"context": context, "question": state["question"]}))
    return {"answer": resp.content, "retry_count": state.get("retry_count", 0) + 1}

def generate_direct(state: AdaptiveRAGState) -> dict:
    """不需要检索时直接回答"""
    resp = llm.invoke(state["question"])
    return {"answer": resp.content}

class AnswerGrade(BaseModel):
    grounded: bool = Field(description="回答是否完全基于资料,没有幻觉")

answer_grade_llm = llm.with_structured_output(AnswerGrade)

def grade_answer(state: AdaptiveRAGState) -> dict:
    """检查回答是否基于资料(无幻觉)"""
    docs = state["relevant_docs"]
    context = "\n\n".join(d.page_content for d in docs)
    answer = state["answer"]
    prompt = (
        "判断回答是否完全基于下面资料,没有编造资料外的内容。\n\n"
        f"资料:{context}\n\n回答:{answer}"
    )
    result = answer_grade_llm.invoke(prompt)
    # 把"是否合格"塞回状态,供条件边读取
    return {"answer": answer, "_answer_ok": result.grounded}

五、条件边与路由

条件边是自适应 RAG 的核心,所有"判断"在这里转成"走向"。

python
def route_after_query(state: AdaptiveRAGState) -> str:
    if state["need_retrieval"]:
        return "retrieve"
    return "generate_direct"

def route_after_grade(state: AdaptiveRAGState) -> str:
    # 没有相关文档 → 改写重检索
    if not state.get("relevant_docs"):
        return "rewrite_query"
    return "generate"

def route_after_answer(state: AdaptiveRAGState) -> str:
    # 防死循环:最多重生成 2 次
    if state.get("_answer_ok", False) or state.get("retry_count", 0) >= 3:
        return END
    return "generate"

六、组装图

python
from langgraph.graph import StateGraph, START, END

gb = StateGraph(AdaptiveRAGState)
for name, fn in [
    ("query_analyze", query_analyze),
    ("retrieve", retrieve),       # 复用上一篇的检索节点
    ("grade_docs", grade_docs),
    ("rewrite_query", rewrite_query),
    ("generate", generate),
    ("generate_direct", generate_direct),
    ("grade_answer", grade_answer),
]:
    gb.add_node(name, fn)

gb.add_edge(START, "query_analyze")
gb.add_conditional_edges("query_analyze", route_after_query, {
    "retrieve": "retrieve", "generate_direct": "generate_direct"
})
gb.add_edge("retrieve", "grade_docs")
gb.add_conditional_edges("grade_docs", route_after_grade, {
    "rewrite_query": "rewrite_query", "generate": "generate"
})
gb.add_edge("rewrite_query", "retrieve")     # 改写后重新检索
gb.add_edge("generate", "grade_answer")
gb.add_conditional_edges("grade_answer", route_after_answer, {
    END: END, "generate": "generate"
})
gb.add_edge("generate_direct", END)

adaptive_app = gb.compile()

七、完整可运行示例

下面给一个能跑的最小例子,沿用上一篇的知识库。为节省篇幅只贴关键差异部分,检索器构造同 上一篇

python
import os
from typing import TypedDict, List
from pydantic import BaseModel, Field

from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langgraph.graph import StateGraph, START, END

# ---- 知识库(同上一篇,略)----
docs = [Document(page_content=t) for t in [
    "LangGraph 把流程建模成图,节点是函数,边决定执行顺序。",
    "条件边可以根据状态选择下一个节点,实现分支逻辑。",
    "Python 用缩进表示代码块,不需要大括号。",
]]
vs = InMemoryVectorStore(OpenAIEmbeddings(model="text-embedding-3-small"))
vs.add_documents(docs)
retriever = vs.as_retriever(search_kwargs={"k": 2})

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ---- 状态 ----
class S(TypedDict):
    question: str
    documents: List[Document]
    relevant_docs: List[Document]
    need_retrieval: bool
    answer: str
    retry_count: int
    _answer_ok: bool

# ---- 三个结构化输出模型 ----
class QDec(BaseModel):
    need_retrieval: bool
class DGrade(BaseModel):
    relevant: bool
class AGrade(BaseModel):
    grounded: bool

# ---- 节点 ----
def query_analyze(state):
    d = llm.with_structured_output(QDec).invoke(
        "判断是否需要查知识库(事实/知识性=需要, 闲聊=不需要):\n" + state["question"])
    return {"need_retrieval": d.need_retrieval}

def retrieve(state):
    return {"documents": retriever.invoke(state["question"])}

def grade_docs(state):
    rel = []
    for d in state["documents"]:
        g = llm.with_structured_output(DGrade).invoke(
            f"资料是否与问题相关:\n问题:{state['question']}\n资料:{d.page_content}")
        if g.relevant:
            rel.append(d)
    return {"relevant_docs": rel, "retry_count": state.get("retry_count", 0)}

def rewrite_query(state):
    new = llm.invoke("改写得更利于检索,只输新问题:\n" + state["question"]).content
    return {"question": new, "retry_count": state.get("retry_count", 0) + 1}

def generate(state):
    ctx = "\n\n".join(d.page_content for d in state["relevant_docs"])
    a = llm.invoke(f"只根据资料回答,不足就说不知道。\n资料:{ctx}\n问题:{state['question']}")
    return {"answer": a.content, "retry_count": state.get("retry_count", 0) + 1}

def generate_direct(state):
    return {"answer": llm.invoke(state["question"]).content}

def grade_answer(state):
    ctx = "\n\n".join(d.page_content for d in state["relevant_docs"])
    g = llm.with_structured_output(AGrade).invoke(
        f"回答是否基于资料无幻觉:\n资料:{ctx}\n回答:{state['answer']}")
    return {"_answer_ok": g.grounded}

# ---- 路由 ----
def r1(state): return "retrieve" if state["need_retrieval"] else "generate_direct"
def r2(state): return "rewrite_query" if not state.get("relevant_docs") else "generate"
def r3(state): return END if (state.get("_answer_ok") or state.get("retry_count",0)>=3) else "generate"

# ---- 建图 ----
gb = StateGraph(S)
for n, f in [("query_analyze",query_analyze),("retrieve",retrieve),("grade_docs",grade_docs),
             ("rewrite_query",rewrite_query),("generate",generate),
             ("generate_direct",generate_direct),("grade_answer",grade_answer)]:
    gb.add_node(n, f)
gb.add_edge(START, "query_analyze")
gb.add_conditional_edges("query_analyze", r1, {"retrieve":"retrieve","generate_direct":"generate_direct"})
gb.add_edge("retrieve", "grade_docs")
gb.add_conditional_edges("grade_docs", r2, {"rewrite_query":"rewrite_query","generate":"generate"})
gb.add_edge("rewrite_query", "retrieve")
gb.add_edge("generate", "grade_answer")
gb.add_conditional_edges("grade_answer", r3, {END:END,"generate":"generate"})
gb.add_edge("generate_direct", END)
app = gb.compile()

if __name__ == "__main__":
    for q in ["条件边是干嘛的?", "你好呀"]:
        r = app.invoke({"question": q, "retry_count": 0})
        print(q, "=>", r["answer"], "| 检索:", r.get("need_retrieval"))

第二个问题"你好呀"会被 query_analyze 判为不需要检索,直接走 generate_direct,省掉一轮检索和打分。

八、常见踩坑

1. 评分 prompt 设计太松或太严

  • 太松:所有文档都判"相关",等于没筛。
  • 太严:相关文档也被判不相关,陷入"改写→检索→全不相关→再改写"的死循环。

调试建议:先固定一组问答对,把评分节点的判断打印出来人工核对,再调 prompt 措辞。结构化输出(with_structured_output)比让模型回 "yes/no" 字符串稳得多。

2. 循环过多导致 token 失控

rewrite → retrieve → grade → rewritegenerate → grade → generate 都是潜在死循环。任何带循环的图都必须有 retry_count 上限,本例用 retry_count >= 3 强制退出。生产环境还要叠加 token 总量上限和单次请求超时。

3. 条件边返回了未注册的节点名

add_conditional_edges 的第二个参数返回的字符串必须和 add_node 的名字一致,且要在第三个参数的 path_map 里列出。写错一个字母图就编译不过。建议把 path_map 显式写出来(如上例),别让 LangGraph 自己猜。

4. 状态字段污染

_answer_ok 这种"内部信号"字段一旦写进状态,下游节点都能看到。如果不想让它进最终输出或干扰别的判断,命名上加下划线前缀做约定,并在出口节点显式清理。本系列 实战项目 里会更系统地处理状态卫生。

九、小结

  • 自适应 RAG = 基础 RAG + 三个评估节点(查询分析、文档评分、回答评分)+ 条件边路由。
  • 三个评估点分别决定:要不要检索、要不要重检索、要不要重生成。
  • 结构化输出(with_structured_output)是让 LLM"打分"最稳的方式。
  • 带循环的图必须有 retry_count 刹车,否则会无限重试烧钱。

下一篇 自纠正 RAG (CRAG) 会引入更精细的"三档评分"和 web 搜索兜底,进一步处理检索质量不行的情况。