Agentic RAG(下):ES + Milvus 多路召回,再用 Rerank 去噪

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,让回答可追溯,而不是只返回一段没有证据的自然语言。

结尾

混合检索不是把两个数据库的结果简单拼起来。可复用的完整链路是:

  1. 查询扩展增加表达覆盖,但保留实体字面值;
  2. ES 和 Milvus 并行召回,各做擅长的事情;
  3. 使用统一业务 ID 合并去重,不直接比较两种分数;
  4. Rerank 对候选集做跨文档重排;
  5. 只把 Top N 文档送入模型;
  6. 无结果时明确拒绝编造;
  7. 对单路失败、超时和总调用预算设计降级。

前两篇解决"何时检索"和"复杂问题如何逐步检索",这一篇解决"单次检索怎样兼顾召回率与上下文质量"。三者可以组合,但我不会一开始就把所有节点塞进一张图:先为每条链建立独立评测,再决定哪些复杂度真的带来收益。下一步是准备带相关性标注的固定问题集,分别记录 ES、Milvus、合并候选和 Rerank 后的命中情况。

相关推荐
杨杨杨大侠1 小时前
从一次请求讲透 A2A:Agent 怎么选、怎么协作、记忆放在哪里
人工智能·agent
后端小肥肠2 小时前
做公众号漫画还在纯手搓?这套工具从选题到复刻任意热门漫画一键搞定
人工智能·aigc·agent
阿里云云原生3 小时前
让 Agent 学会判断:从 JEV 接入到完整 Harness 决策流程
agent
阿里云云原生3 小时前
阿里云 AgentCore 产品负责人解读产品全景
云原生·agent
10年前端老司机4 小时前
我用 Vue3 + LangGraph 做了个心理咨询 AI,踩了 9 个坑后总结出来的实战经验
前端·langchain·agent
空堂与归4 小时前
REA登顶GitHub:AI如何逆向一切软件
ai·大模型·agent
wewowilling5 小时前
RAG知识库--coze
大模型·agent·知识库·rag·coze
DigitalOcean5 小时前
DigitalOcean 无服务器推理实测:六款模型的首 Token 延迟表现
llm·agent
效率工作实验室5 小时前
2026年企业级 AI 智能体平台选型指南:关键评估维度
ai·agent·企业级·智能体平台·智能体平台选型