Agentic RAG(下):ES + Milvus 多路召回,再用 Rerank 去噪
只用向量检索时,我遇到过一个很典型的问题:自然语言意思接近的内容容易找出来,但订单号、型号、专有名词这类精确字符串不一定稳定命中。反过来,关键词检索能准确找到字面词,却不理解"无线总掉线"和"Wi-Fi 连接不稳定"表达的是同一类问题。
所以我把检索链改成了混合检索(Hybrid Retrieval):查询扩展 → ES 与 Milvus 并行召回 → 合并去重 → Rerank → Top N 文档生成答案。
下面按代码拆解这条链路。Docker、Elasticsearch、Milvus 和外部 Rerank API 都需要自行配置,因此不提供未经固定评测集验证的速度或准确率。
1. 两种检索各自擅长什么
Elasticsearch(简称 ES)依赖倒排索引(Inverted Index):从词条快速找到包含它的文档。它适合:
- 产品型号、姓名、订单号等精确实体;
- 中文分词后的全文搜索;
- 标签、作者、时间等字段过滤;
- 使用 BM25 进行关键词相关性排序。
Milvus 保存 Embedding 向量,按距离或相似度找语义接近的文本。它适合:
- 同义表达;
- 用户问题与文档用词不一致;
- 语义相近但没有共同关键词的内容。
方案 A 是只选其中一个,部署简单;方案 B 是两路召回,覆盖面更大,但会引入重复文档、分数不可直接比较和更多基础设施。只有在业务确实同时存在精确实体与自然语言表达时,混合检索才值得增加复杂度。
2. 先让 ES 正确处理中文
这里使用 Elasticsearch 8.17.0,并在镜像中安装 IK 中文分词插件:
dockerfile
FROM elasticsearch:8.17.0
RUN elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/\
elasticsearch-analysis-ik-8.17.0.zip
开发环境的 Compose 同时启动 ES 和 Kibana。原配置关闭了安全认证,只适合本地隔离网络,不能照搬到公网。
创建索引时,写入与查询使用不同粒度的分析器:
js
await esClient.indices.create({
index: "life_notes",
mappings: {
properties: {
note_title: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
note_body: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
tags: { type: "keyword" },
priority: { type: "integer" },
created_at: { type: "date" },
},
},
});
text 会分词,适合全文搜索;keyword 把整个值当作一个词,适合过滤和聚合。把标签也定义成 text,可能让一个完整标签被拆散;把正文定义成 keyword,又无法正常全文检索。
3. Query Augmentation:先扩查询,不要编事实
原实现先让模型生成三条不同角度的检索问句。这里叫查询扩展(Query Augmentation),目的不是改答案,而是增加召回入口:
js
const QueryAugmentSchema = z.object({
queries: z.array(z.string()).length(3),
});
async function augmentQuery(chatModel, query) {
const structured =
chatModel.withStructuredOutput(QueryAugmentSchema);
try {
const result = await structured.invoke(`
把用户问题改写成恰好 3 条中文检索问句。
保持原意,角度尽量不同;
订单号、品牌、型号等字面信息必须原样保留;
不要补充用户没有提供的事实。
问题:${query}
`);
return result.queries;
} catch {
// 结构化输出失败时,至少保留原查询
return [query, query, query];
}
}
结构化输出失败时,用原问题补足三条是一种简单降级:宁可重复检索,也不要因为扩写失败让整条链路中断。
但查询扩展会把一次检索放大成多次。三条扩写加原问题,再分别查询 ES 和 Milvus,就是八次查询。真实系统应该控制并发、超时和总预算。
4. 两路召回并行执行
ES 使用 multi_match,并给标题更高权重:
js
async function recallFromElasticsearch(queries) {
const batches = await Promise.all(
queries.map((query) =>
esClient.search({
index: "life_notes",
size: 4,
query: {
multi_match: {
query,
fields: ["note_title^2", "note_body"],
type: "best_fields",
analyzer: "ik_smart",
},
},
}),
),
);
return batches.flatMap((response) =>
response.hits.hits.map(documentFromEsHit),
);
}
// Milvus 对每条问句做相似度检索
async function recallFromMilvus(queries) {
const batches = await Promise.all(
queries.map((query) =>
milvus.similaritySearch(query, 4),
),
);
return batches.flat();
}
在 LangGraph 里,query_augment 同时连接 es_recall 和 milvus_recall,两者完成后再进入 merge。这比先查 ES、再查 Milvus 更节省等待时间,但需要处理其中一路失败的情况。学习代码使用 Promise.all,任何一个请求拒绝都会让整个节点失败;生产代码可以使用 Promise.allSettled,允许单路降级。
5. 合并时不要比较 ES 分数与向量分数
ES 的 BM25 分数和 Milvus 的相似度不是同一量纲,直接排序没有意义。原实现选择先统一成 LangChain Document,再按业务 ID 去重:
js
function dedupeDocumentsById(documents) {
const seen = new Set();
const output = [];
for (const document of documents) {
const id = String(document.metadata?.id ?? "").trim();
if (!id || seen.has(id)) continue;
seen.add(id);
output.push(document);
}
return output;
}
const merged = dedupeDocumentsById([
...esDocuments,
...milvusDocuments,
]);
这要求同一条业务数据在 ES 和 Milvus 中使用同一个稳定 ID。否则相同内容会以两条文档进入下游。
方案 A 是按 ID 去重,快且可靠,但依赖数据同步规范;方案 B 是按内容哈希或相似度去重,能处理不同 ID,却增加计算和误判。能控制写入链路时,我优先统一 ID。
6. Rerank:召回负责"别漏",重排负责"别吵"
混合召回的目标是覆盖,因此通常会带来更多噪声。Rerank(重排序)模型接收"问题 + 文档",给每条文档计算更精细的相关性,再保留前几条。
这里封装了一个 BaseDocumentCompressor:
js
class DashscopeRerank extends BaseDocumentCompressor {
constructor({ apiKey, model, topN, baseUrl }) {
super();
this.apiKey = apiKey;
this.model = model;
this.topN = topN;
this.baseUrl = baseUrl;
}
async compressDocuments(documents, query) {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: this.model,
input: {
query,
documents: documents.map((doc) => doc.pageContent),
},
parameters: {
return_documents: false,
top_n: this.topN,
},
}),
});
if (!response.ok) {
throw new Error(`Rerank 请求失败:${response.status}`);
}
const result = await response.json();
return result.output.results.map(
(item) => documents[item.index],
);
}
}
所有 Key 都从环境变量读取,不在代码和日志中输出真实值。
Rerank 不是第二次召回。召回阶段如果完全漏掉正确文档,重排模型也无能为力。因此两阶段的目标不同:
- Recall:尽量让相关文档进入候选集;
- Rerank:从候选集中去噪,并压缩送入大模型的上下文。
7. 用 LangGraph 串起整条链
原项目把流程表达成一个图:
js
const graph = new StateGraph(HybridRetrievalState)
.addNode("query_augment", queryAugmentNode)
.addNode("es_recall", esRecallNode)
.addNode("milvus_recall", milvusRecallNode)
.addNode("merge", mergeNode)
.addNode("rerank", rerankNode)
.addNode("generate_answer", generateAnswerNode)
.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();
原 rerank 节点写成 if (!merge.length),检查的是函数 merge 的形参数量,而不是合并后的数组。整理后的正确判断应是:
js
async function rerankNode(state) {
const merged = state.merged ?? [];
if (merged.length === 0) {
return { topDocuments: [] };
}
return {
topDocuments: await reranker.compressDocuments(
merged,
state.query,
),
};
}
这是一个很隐蔽的 JavaScript 坑:函数也有 length 属性,代码不会立刻抛错,却表达了完全不同的意思。文章不会把原错误当作已运行通过的实现。
8. 没有上下文时,生成节点应该走另一条提示词
生成阶段准备两套 Prompt:
- 有文档:只根据检索片段回答;
- 无文档:明确说明知识库中没有,并请求用户补充关键词。
这比把空字符串塞进同一个 Prompt 更清楚。生成节点还应该保留文档 ID 或来源 URL,让回答可追溯,而不是只返回一段没有证据的自然语言。
结尾
混合检索不是把两个数据库的结果简单拼起来。可复用的完整链路是:
- 查询扩展增加表达覆盖,但保留实体字面值;
- ES 和 Milvus 并行召回,各做擅长的事情;
- 使用统一业务 ID 合并去重,不直接比较两种分数;
- Rerank 对候选集做跨文档重排;
- 只把 Top N 文档送入模型;
- 无结果时明确拒绝编造;
- 对单路失败、超时和总调用预算设计降级。
前两篇解决"何时检索"和"复杂问题如何逐步检索",这一篇解决"单次检索怎样兼顾召回率与上下文质量"。三者可以组合,但我不会一开始就把所有节点塞进一张图:先为每条链建立独立评测,再决定哪些复杂度真的带来收益。下一步是准备带相关性标注的固定问题集,分别记录 ES、Milvus、合并候选和 Rerank 后的命中情况。