一、 引言
在上篇中,我们完成了 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变3) + 混合检索(ES+Milvus多路召回)。
- 提升召回质量:去重 + Rerank重排,优中选优。
- 节省资源:所有问题先经过改写评估,再决定检索策略。
后续可以继续优化的方向(Roadmap):
- 评估机制 :引入
LangSmith进行全链路Agent观测,评估RAG的召回率和准确率。 - 短期记忆 :接入
Redis实现后端缓存与Agent短期记忆。 - 工具调用 :向
DeepAgents演进,引入开箱即用的skill和上下文压缩中间件。 - 联网兜底:当本地知识库确实无法回答时,引入Web搜索兜底。
- 部署 :使用
LangFuse进行Agent观测与部署。
RAG不是简单的"Embedding + 向量库",而是一套复杂的信息检索与编排工程。希望这套基于LangGraph的实战架构能为你提供新的思路!