从朴素RAG到Agentic RAG(二):基于LangGraph的混合检索、重排与闭环设计

一、 引言

在上篇中,我们完成了 ES 和 Milvus 的数据双写,并利用 LLM 实现了 Query 的 1 变 3 扩展。现在我们进入整个 Agentic RAG 的核心逻辑层:基于 LangGraph 的检索、合并、重排与生成编排。

正如手稿中总结的流程:

用户输入 query -> 拆分不同角度生成3个子问题 -> 多路召回(向量检索 + 关键词检索) -> 结果合并去重 -> 重排(ReRank) -> 取n条高质量文档送入 LLM -> 生成最终回答。


二、 LangGraph 状态定义与节点编排

(参考 hybrid-retrieval.mjs)

LangGraph 允许我们以状态图(StateGraph)的方式定义复杂的 AI 工作流。我们需要先定义图的"状态"(State),即在整个流程中流转的数据结构。

javascript

javascript 复制代码
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";

const HybridRetrievalState = Annotation.Root({
    query: Annotation(),             // 原始问题
    queryAugmentation: Annotation(), // 扩展后的3个问题
    esHits: Annotation(),            // ES召回结果
    milvusHits: Annotation(),        // Milvus召回结果
    merged: Annotation(),            // 合并去重后的结果
    topDocuments: Annotation(),      // 重排后保留的Top-N文档
    answer: Annotation()             // 最终LLM生成的答案
});

2.1 并行召回节点(ES & Milvus)

这是 LangGraph 的强项。es_recall 和 milvus_recall 会在同一超步(Super-step)中并行执行,大幅降低响应时间。

ES 召回节点:

针对3个扩展Query,分别去ES里做 multi_match 查询,标题字段增加权重(^2)。最后将结果扁平化并去重。

javascript

javascript 复制代码
.addNode("es_recall", async (state) => {
    const qs = retrievalQueryString(state.query, state.queryAugmentation);
    const n = Math.max(1, qs.length);
    const kEach = Math.max(2, Math.ceil(ES_K / n)); // 每个子查询召回的数量
    const batches = await Promise.all(
        qs.map(q => esClient.search({
            index: INDEX,
            size: kEach,
            query: {
                multi_match: {
                    query: q,
                    fields: ["note_title^2", "note_body", "title", "content"],
                    type: "best_fields",
                    analyzer: "ik_smart" // 查询时使用粗粒度分词
                }
            }
        }))
    );
    // 扁平化并去重
    const flat = batches.flatMap((res) => (res.hits?.hits ?? []).map(docFromEsHit));
    return { esHits: dedupeDocsById(flat) };
})

Milvus 召回节点:

同样针对3个Query并行去向量库检索。

javascript

ini 复制代码
.addNode("milvus_recall", async (state) => {
    const qs = retrievalQueryString(state.query, state.queryAugmentation);
    const kEach = Math.max(2, Math.ceil(MILVUS_K / n));
    const batches = await Promise.all(qs.map((q) => milvus.similaritySearch(q, kEach)));
    const flat = batches.flat();
    return { milvusHits: dedupeDocsById(flat) };
})

💡 为什么要去重?多路召回必然导致大量重复文档,如果不提前去重,不仅浪费后续 Rerank 的算力,还会挤占大模型的上下文窗口。

2.2 合并节点(Merge)

将 ES 和 Milvus 的结果进行合并。这里可以简单地将两个数组合并后按 ID 去重。

javascript

less 复制代码
.addNode("merge", async (state) => ({
    merged: merge(state.esHits, state.milvusHits)
}))

三、 核心环节:Rerank 重排机制

(参考 dashscope-rerank.mjs)

手稿中精准地指出: "向量召回只能判断问题相似度,重排就可以判断:这一段文本能不能回答这个问题。"

混合召回带来了大量冗余信息,大模型上下文窗口有限,必须"先过滤,再精简,才能回答准确"。Rerank 模型就是专门用来给 RAG 去噪、筛选、重新排序的,它体积小、推理快、成本极低。

我们继承 LangChain 的 BaseDocumentCompressor,无缝接入 DashScope 的 qwen3-rerank 模型:

javascript

javascript 复制代码
import { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';

export class DashscopeRerank extends BaseDocumentCompressor {
    constructor({ apiKey, model = 'qwen3-rerank', topN = 3, baseUrl }) {
        super();
        this.apiKey = apiKey;
        this.model = model;
        this.topN = topN;
        this.baseUrl = baseUrl ?? process.env.RERANK_BASE_URL;
    }

    async compressDocuments(documents, query, _callbacks) {
        const res = await fetch(this.baseUrl, {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${this.apiKey}`,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                model: this.model,
                input: {
                    query: query,
                    documents: documents.map(d => d.pageContent), // 只传文本内容
                },
                parameters: {
                    return_documents: false,
                    top_n: this.topN,
                }
            }),
        });
        const json = await res.json();
        const results = json?.output?.results;
        
        // 根据返回的 index 重新映射回 LangChain 的 Document 对象
        return results.map(item => documents[item.index]);
    }
}

在 LangGraph 中调用重排节点:

javascript

ini 复制代码
.addNode("rerank", async (state) => {
    const merged = state.merged ?? [];
    if (!merged.length) return { topDocuments: [] };
    // 调用重排模型,优中选优
    const topDocuments = await reranker.compressDocuments(merged, state.query);
    return { topDocuments };
})

四、 最终生成:Prompt 工程与防幻觉

在拿到重排后的高质量文档后,我们将它们拼接到 Prompt 中。为了最大程度避免幻觉,我们需要设计严格的 ANSWER_PROMPT,并设置无上下文时的兜底 NO_CONTEXT_PROMPT。

javascript

ini 复制代码
const ANSWER_PROMPT = ChatPromptTemplate.fromMessages([
    ["system", `你是阅读用户[生活笔记]知识库并作回答的助手。
        规则:
        - 只根据下方[检索片段] 推断答案;片段里没有的信息不要编造。
        - 若片段不足以回答问题,明确说明 [笔记里没提到], 并可给出一句保守建议。
        - 回答简洁有条理,可使用简短列表,口吻自然中文。`],
    ["human", `用户问题:{query}\n检索片段:\n{context}`]
]);

.addNode("generate_answer", async (state) => {
    const docs = state.topDocuments ?? [];
    if (!docs.length) {
        const chain = NO_CONTEXT_PROMPT.pipe(chatModel);
        const msg = await chain.invoke({ query: state.query });
        return { answer: stringifyMessageContent(msg.content).trim() };
    }
    const chain = ANSWER_PROMPT.pipe(chatModel);
    const msg = await chain.invoke({ query: state.query, context: formatDocsAsContext(docs) });
    return { answer: stringifyMessageContent(msg.content).trim() };
})

五、 编排图结构与总结

最后,我们将所有节点编排成一张有向图:

javascript

arduino 复制代码
return new StateGraph(HybridRetrievalState)
    .addNode("query_augment", ...)
    .addNode("es_recall", ...)
    .addNode("milvus_recall", ...)
    .addNode("merge", ...)
    .addNode("rerank", ...)
    .addNode("generate_answer", ...)
    .addEdge(START, "query_augment")
    .addEdge("query_augment", "es_recall")
    .addEdge("query_augment", "milvus_recall")
    .addEdge(["es_recall", "milvus_recall"], "merge") // 并行节点汇聚
    .addEdge("merge", "rerank")
    .addEdge("rerank", "generate_answer")
    .compile();

架构回顾与后续演进(Agentic RAG)

这套系统实现了真正意义上的 Agentic RAG(闭环系统) :

  1. 提升召回率:查询扩展(1变3) + 混合检索(ES+Milvus多路召回)。
  2. 提升召回质量:去重 + Rerank重排,优中选优。
  3. 节省资源:所有问题先经过改写评估,再决定检索策略。

后续可以继续优化的方向(Roadmap):

  • 评估机制 :引入 LangSmith 进行全链路Agent观测,评估RAG的召回率和准确率。
  • 短期记忆 :接入 Redis 实现后端缓存与Agent短期记忆。
  • 工具调用 :向 DeepAgents 演进,引入开箱即用的skill和上下文压缩中间件。
  • 联网兜底:当本地知识库确实无法回答时,引入Web搜索兜底。
  • 部署 :使用 LangFuse 进行Agent观测与部署。

RAG不是简单的"Embedding + 向量库",而是一套复杂的信息检索与编排工程。希望这套基于LangGraph的实战架构能为你提供新的思路!

相关推荐
坊钰1 小时前
【LangGraph框架入门级】12. 人机交互:interrupt 中断与 Command 恢复
开发语言·数据库·python·人机交互·ai编程·langgraph
Solara1 小时前
一个标志位挂了 18 天不落地:它等的重启,2 天 14 小时后就完成了
人工智能·程序员·ai编程
SkyWalking中文站1 小时前
从模型输入到文件变更:追踪 AgentCore 上的 LangGraph Agent
langchain·aigc·ai编程
金銀銅鐵2 小时前
[Java] 用 GUI 展示 ArrayList 扩容的过程
java·后端·ai编程
NC 王炸2 小时前
AI 做的便签,换个浏览器就没了?加一个导出按钮,把数据带走
javascript·ai编程·数据备份·localstorage·txt导出
凯歌的博客15 小时前
DuckDB 是一款进程内的分析型数据库
数据库·ai编程
Tangyuewei16 小时前
Agent 别裸奔进生产:先搭架构、可观测、人审三道闸
人工智能·ai编程
用户40158233246216 小时前
不开编辑器,在飞书里也能找 WES Code 的 AI 干活
人工智能·ai编程