基于Spring AI+Milvus的RAG混合检索的实战指南

 更新时间:2026年06月02日 09:05:21   作者:轮子飞了  
这篇文章主要给大家记录了从零搭建企业级 RAG 知识库问答系统的工程,涵盖意图路由、混合检索、RRF 融合、query 改写、rerank 精排全链路,并通过代码示例讲解的非常详细,需要的朋友可以参考下

1. 背景与动机

1.1 业务场景

我们要为一个垂直业务平台构建智能客服助手。用户描述自己遇到的产品问题——“产品A 无法启动怎么排查”、“产品B 多久保养一次”——系统需要给出准确的解决方案。

数据侧,我们已经整理了大量的产品技术文档(PDF、DOCX),按产品线(产品A、产品B、产品C、产品D、产品E)分类。现在需要一套检索增强生成(RAG)系统,把这些文档"喂"给 LLM,生成结构化的解决方案。

1.2 核心挑战

直接做一个"用户问 → 向量检索 → LLM 回答"的朴素 RAG 够用吗?不够。

实际场景中我们面临几个关键问题:

  1. 意图多样:不是所有问题都需要 RAG。“你好”、"我之前提交的工单怎样了"这类不需要检索,直接走 LLM 或者查数据库即可。每次都触发全套检索链路是浪费。
  2. 精准召回难:纯向量检索对专业术语的敏感性有限。"产品C 运行异常"和"产品C 系统故障"在语义空间里可能很近,但纯关键词匹配可能漏掉。
  3. 检索噪声:召回 40 条候选里,可能有 50% 以上和问题无关。直接塞给 LLM 不仅浪费 token,还容易让 LLM 被噪声带偏。
  4. 领域术语差异:用户说"设备发热",文档写的是"机身温度过高";用户说"接口松动",文档写的是"连接端口接触不良"。不做 query 扩展根本搜不到。

1.3 选型

组件选型理由
框架Spring Boot 3.4.5 + Spring AI 1.1.2Java 生态成熟,Spring AI 封装了向量存储和 LLM 调用
向量数据库Milvus 2.5.6原生支持 BM25 内置函数,免去额外 ES 依赖
嵌入模型DashScope text-embedding-v21536 维,中文效果好
LLMDashScope Qwen两阶段调用(意图分类 + 答案生成),同一模型不同 system prompt
RerankDashScope Rerank与 LLM 同供应商,延迟可控

2. 整体架构概览

整个问答系统的核心 pipeline 分为两阶段

┌─────────────────────────────────────────────────────────┐
│                    用户输入(问题文本)                      │
└─────────────────────┬───────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────┐
│              Phase 1: 意图分类(轻量 LLM 调用)              │
│                                                         │
│  ┌─────────────┐  ┌──────────────────┐  ┌────────────┐ │
│  │GENERAL      │  │VIEW_RECENT       │  │FIND        │ │
│  │_CONSULT     │  │_FOLLOWUP_TASKS   │  │_COMPONENT  │ │
│  │一般咨询      │  │查看历史记录       │  │查找解决方案  │ │
│  └──────┬──────┘  └────────┬─────────┘  └─────┬──────┘ │
└─────────┼──────────────────┼──────────────────┼────────┘
          │                  │                  │
          ▼                  ▼                  ▼
   LLM 直接回复      查数据库返回         触发 Phase 2
   (可选同步 QA 库)   历史记录             RAG 检索链路
                                             │
                                             ▼
                          ┌─────────────────────────────────┐
                          │    Phase 2: RAG 混合检索          │
                          │                                 │
                          │  Query 改写 → 向量检索 + BM25     │
                          │       → RRF 融合 → Rerank        │
                          │       → 后处理门控 → LLM 生成     │
                          └─────────────────────────────────┘

关键设计决策:意图优先,RAG 按需触发。只有 FIND_COMPONENT 意图才走完整的混合检索链路,其他意图直接走轻型路径。这样做的好处是:

  • 减少不必要的 LLM 调用次数(Phase 1 和 Phase 2 各自只调一次 LLM)
  • 降低平均延迟(大部分请求不需要 RAG)
  • Phase 1 分类 + Phase 2 生成的两次 LLM 调用可针对各自任务分别调参

核心服务类关系

层级关键类职责
Web 层AssistantController, RagControllerREST 接口
核心 pipelineDoctorAssistantService两阶段 LLM pipeline
RAG 检索RagQueryService混合检索编排
Query 改写RagQueryRewriteService同义扩展 + 领域词映射
文档摄入DocumentIngestService文档上传、文本提取、切片
Milvus BM25MilvusBuiltInBm25KeywordSearchMilvus 内置 BM25 全文检索
Milvus 建表MilvusBm25CollectionProvisionerBM25 集合 schema 自动创建
Milvus v2 写入MilvusBm25V2DocumentWriter适配 BM25 函数字段的数据写入

3. 意图路由:轻量分类 + RAG 按需触发

3.1 方案选型

在要不要先做意图分类这个问题上,我们的权衡是这样的:

  • 不做分类,直接 RAG:简单粗暴,但每个"你好"都要跑一遍检索链路,浪费算力和延迟。
  • 两阶段 LLM:Phase 1 用轻量 prompt 做分类,Phase 2 只在需要时才触发。

我们选择了后者。Phase 1 的意图分类 prompt 要求 LLM 返回严格的 JSON:

你是产品技术支持领域的意图识别专家。根据用户问题,识别意图类型。
必须严格返回JSON格式:
{"intentType": "GENERAL_CONSULT"|"FIND_COMPONENT"|"VIEW_RECENT_FOLLOWUP_TASKS"}

这个阶段 LLM 的推理负担极轻——不需要理解全文,不需要检索上下文,只需要判断用户想干什么。

3.2 三种意图的处理路径

意图处理方式延迟
GENERAL_CONSULTLLM 直接回答,可选同步到 QA 库
VIEW_RECENT_FOLLOWUP_TASKS调用外部用户服务查历史数据
FIND_COMPONENT触发完整 RAG pipeline → LLM 生成解决方案

只有 FIND_COMPONENT 一个意图会触发 RAG。而且这里还有一个前置校验——通过外部服务确认用户确实关联了某种产品配置。如果用户没有相关产品却问"产品A 故障怎么办",系统不会浪费时间去检索文档。

3.3 后处理门控

即使 RAG 召回了文档,LLM 生成了回答,还有一个后处理门控:

  1. LLM 返回的答案中包含 componentCode(产品类型编码)
  2. 系统检查这个编码是否与用户实际关联的产品类型一致
  3. 如果不一致——比如用户关联的是产品B,但 RAG 召回了产品A 的相关文档——系统将此回答降级为 GENERAL_CONSULT,即只给一般性建议,不给具体操作指引

这个门控很重要,因为在业务场景下,给错产品线的操作建议比不给更危险

4. 混合检索 pipeline:向量 + BM25 + RRF 融合

混合检索是 RAG 系统的核心。我们来详细拆解每一步。

4.1 为什么要混合检索

纯向量检索(dense retrieval)的问题是:

  • 向量相似 ≠ 关键词匹配。"产品C 故障"和"产品C 运行异常"语义相近,但用户搜索"产品C"这个词时,向量检索不一定能准确区分"产品C"和"产品A系列"的文档。
  • 对稀有词、专业名词的召回容易漂移。向量模型在训练时可能没见过足够的该领域语料。

纯关键词检索(BM25 sparse retrieval)的问题是:

  • 词汇不匹配就没结果。用户说"设备发热",BM25 搜不到写了"机身温度过高"的文档。
  • 无法理解语义。"产品A 卡顿"和"设备无响应"没有共同词,BM25 得分是 0。

两者结合,优势互补。

4.2 技术选型:Milvus 2.5 内置 BM25

市面上常见的混合检索方案通常需要两个引擎:Milvus/Qdrant 做向量 + Elasticsearch 做 BM25。维护两套索引,还要在两个结果集之间做融合,架构复杂度不小。

Milvus 2.5 开始支持 内置 BM25——通过 FunctionType.BM25 在 schema 中定义一个函数,将文本字段自动转换为稀疏向量。检索时用 EmbeddedText 将查询文本发给 Milvus,服务端自动分词并计算 BM25 分数。

好处很明显:

  • 单一数据库,不需要维护 ES 集群
  • 向量检索和 BM25 检索可以复用同一套过滤条件(documentId、documentSource)
  • 部署简单

代价是:

  • BM25 参数调优受限于 Milvus 的暴露程度(我们可以在建索引时配置 k1、b,但运行时不可动态调整)
  • 分词依赖 Milvus 内置的 analyzer(我们用的是 Chinese analyzer)

4.3 检索 pipeline 代码拆解

核心入口在 RagQueryService.retrieveForRag()

                     用户问题
                        │
                        ▼
              queryRewriteService
                .rewriteForRetrieval()     ← 领域改写(见第5节)
                        │
                        ▼
          ┌─────────────┴─────────────┐
          │      hybrid enabled?       │
          └─────────────┬─────────────┘
                  Yes   │   No
          ┌─────────────┴─────────────┐
          ▼                           ▼
   ┌──────────────┐           ┌──────────────┐
   │ 并行双路检索   │           │ 纯向量检索     │
   │              │           │ topK=N       │
   │ 向量池: 40条 │           │ minSim=0.15  │
   │ BM25池: 24条 │           └──────────────┘
   └──────┬───────┘
          ▼
     RRF 融合 (k=60)
          │
          ▼
    截断到 max(topK, 48)
          │
          ▼
    DashScope Rerank
          │
          ▼
    多重过滤 → 返回 topK

如果 hybrid.enabled=false,整个链路退化为纯向量检索,用于做对照实验。

4.4 向量检索

使用 Spring AI 封装的 Milvus 向量存储,配置 IVF_FLAT 索引 + COSINE 相似度:

MilvusSearchRequest request = MilvusSearchRequest.milvusBuilder()
    .query(retrievalQuery)
    .topK(topK)
    .searchParamsJson("{\"nprobe\":128}")
    .similarityThreshold(0.15)  // 过滤低分噪声
    .build();
return milvusVectorStore.similaritySearch(request);

几个要点:

  • nprobe=128:IVF_FLAT 索引搜索时扫描 128 个最近的聚类中心,在召回率和速度间取平衡
  • similarityThreshold=0.15:COSINE 相似度低于 0.15 的直接丢弃,减少噪声
  • 向量池取 max(topK, 40) 条——即使请求只需要 5 条,也要多召回一些给后续融合留空间

4.5 Milvus BM25 检索

BM25 检索通过 MilvusBuiltInBm25KeywordSearch 实现,直接调用 Milvus v2 gRPC API:

SearchReq.builder()
    .collectionName(collectionName)
    .annsField("sparse_bm25")           // 稀疏向量字段名
    .data(List.of(new EmbeddedText(query)))  // 服务端自动分词
    .topK(topK)
    .metricType(IndexMetricType.BM25)
    .outputFields(List.of("id", "content", "metadata"))
    .filter(filter)                      // 与向量检索一致的过滤条件
    .build();

关键设计点:

  1. 使用 EmbeddedText 而非手动 BM25 向量化:把原始查询文本发给 Milvus,让服务端用建库时相同的 Chinese analyzer 分词后做 BM25 匹配。这保证了词法对齐——入库分析和查询分析用的同一套 tokenizer。
  2. 词法池取 max(topK, 24):比向量池的 40 小,因为实际场景中查询词一般较短(一两句话),BM25 能有效匹配的文档数本来就有限。
  3. BM25 绝对分过滤:我们加了一个 minBm25AbsoluteScore=1.0 的阈值。BM25 分数低于 1.0 的文档基本没有有意义的词法匹配,属于噪声。
  4. BM25 相对分过滤:同一批次内,BM25_score / max(BM25_score) 低于 0.8 的丢弃。这个阈值可以配置。

4.6 RRF 融合

Reciprocal Rank Fusion(RRF)是一种简洁有效的分数融合方法:

// 伪代码
Map<docId, rrfScore> scores = new HashMap<>();

int rank = 1;
for (doc : vectorRankedList) {
    scores.merge(doc.getId(), 1.0 / (rrfK + rank), Double::sum);
    rank++;
}

rank = 1;
for (doc : keywordRankedList) {
    scores.merge(doc.getId(), 1.0 / (rrfK + rank), Double::sum);
    rank++;
}

return scores.entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .limit(rerankCandidateMax)
    .toList();

RRF 公式:RRF_score(d) = Σ 1 / (k + rank_i(d)),其中 k 是阻尼常数。

为什么用 RRF 而不是简单的分数归一化?

传统分数融合需要做 score normalization——比如 min-max 归一化。但问题是:

  • 向量相似度和 BM25 分数的分布完全不同,线性归一化的假设不成立
  • BM25 分数理论上无上限,向量 COSINE 相似度在 -1 到 1 之间,归一化后的"权重"很难解释

RRF 不关心原始分数的绝对值,只关心相对排名,天然适合异构检索结果的融合。

k 值的选择:我们设 rrfK=60。k 越大,排名差异的影响越小,更偏向于"同时出现在两个列表中的文档得分高"。k=60 意味着:

  • 只在向量列表中排第 1 名的文档:1/(60+1) = 0.0164
  • 在向量列表排第 10、BM25 列表排第 5 的文档:1/(60+10) + 1/(60+5) = 0.0143 + 0.0154 = 0.0297
  • 同时在两个列表排第 1 的文档:1/61 + 1/61 = 0.0328

可以看到,出现在两个列表中的文档 RRF 分明显更高——这正是我们想要的:两个检索器"共识"的文档排前面

5. Query 改写:领域词扩展与同义映射

5.1 为什么需要改写

前面提到,用户的自然语言和专业文档的书面表述之间存在 gap。一个典型的例子:

用户问题文档表述
“设备发热”“机身温度过高”
“接口松动”“连接端口接触不良”
“多久换一次”“配件更换周期/更换频率”

如果不做改写,向量检索和 BM25 检索都可能漏掉真正相关的文档。

5.2 改写策略

RagQueryRewriteService.rewriteForRetrieval() 执行三步处理:

Step 1: 文本归一化(Normalize)

String normalized = original.trim().replaceAll("\\s+", " ");

最简单的清洗:去首尾空白、合并多余空格。

Step 2: 规则驱动的词扩展

维护了一个包含 27 条规则的映射表,每条规则定义了一组触发词和对应的扩展词:

rule(
    new String[] {"保养周期", "保养频率", "多久保养", "检查周期",
                  "多长时间检测", "维护频率", "多久检查"},
    new String[] {"常规保养间隔", "使用期间检查频率", "保养周期",
                  "安装后维护", "常规保养"}
)

匹配逻辑是:如果用户问题中出现任意一个触发词,就将所有扩展词(未在问题原文中出现的)追加到检索 query 后面。

为什么用规则而不是 LLM 做改写?

  • 确定性:规则扩写的结果是可预测、可调试的。LLM 改写可能引入不确定的语义漂移。
  • 低延迟:规则匹配是 O(n) 字符串查找,毫秒级完成。LLM 改写需要一次完整的推理,延迟 1-3 秒。
  • 可维护性:新增一个领域词映射只需要加一条规则,不需要重新训练或调 prompt。
  • 够用:该业务领域的同义词汇是有限的、可枚举的。花里胡哨的 LLM 改写是 overkill。

当一个领域的同义词是可枚举且有限的,规则引擎就是最好的选择。

Step 3: 产品上下文扩展

如果检测到问题涉及特定的产品线,自动追加对应术语:

if (question.contains("产品A") || question.contains("A系列")) {
    expansionTerms.add("产品A");
    expansionTerms.add("产品A系列(企业级高性能型号)");
    // 追加产品A相关的故障关键词
    expansionTerms.addAll(getKeywordsForProduct(PRODUCT_A));
}

最终,改写后的检索 query 是:

原始问题 + 扩展词1 扩展词2 扩展词3 ...

改写 query 只用于检索,不用于 LLM 生成。生成阶段的 prompt 仍然使用用户的原始问题原文。

6. DashScope Rerank 精排与多重过滤

6.1 为什么需要 Rerank

RRF 融合的结果是基于两个检索器各自排名的折中,但它不知道哪个文档真正回答了用户的问题

Rerank 模型把每个候选文档和用户问题做一对一的语义相关性打分,比向量相似度的 rank 更精细。DashScope 的 Rerank 模型(gte-rerank)专门为这个任务优化过。

6.2 Rerank 调用

RRF 融合后,候选列表先截断到 max(topK, 48) 条,然后调用 DashScope Rerank:

RerankResponse response = rerankModel.call(new RerankRequest(
    retrievalQuery,
    fusedDocs,  // candidate documents
    DashScopeRerankOptions.builder()
        .withTopN(topK)
        .build()
));

注意这里的 retrievalQuery改写后的 query(包含扩展词),不是原始问题。因为 Rerank 阶段希望获得更多的检索信号来排序。

6.3 双重过滤

Rerank 返回的结果还要经过两层过滤,而且是 AND 关系:

相对分过滤(batch-normalized):

double maxScore = results.stream()
    .mapToDouble(RerankResult.Result::getRelevanceScore)
    .max().orElse(1.0);

results.stream()
    .filter(r -> r.getRelevanceScore() / maxScore >= 0.8)
    .toList();

以同一批次中最高分为基准,相对分低于 0.8 的丢弃。这保证了返回的文档与问题的相关性不低于"最佳匹配文档"的 80%。

绝对分过滤

results.stream()
    .filter(r -> r.getRelevanceScore() >= 0.3)
    .toList();

Rerank 分数低于 0.3 表示文档基本与问题无关,直接扔掉。

两重过滤的关系是 AND:一个文档必须同时满足相对分 >= 0.8×max 和绝对分 >= 0.3 才会被保留。

6.4 热切换与降级

Rerank 是可热切换的:

RerankModel rerank = hybridProperties.isUseRerankWhenAvailable()
    ? rerankModel.getIfAvailable() : null;

if (rerank == null) {
    // 无 rerank,直接用 RRF 结果截断返回
    return fused.stream().limit(topK).toList();
}

use-rerank-when-available 配置项设为 false 即可跳过 rerank,这对于压测和故障降级很有用。

降级策略:如果 rerank 调用返回空结果,系统回退到 RRF 融合列表截断 topK 输出。但如果 rerank 返回了结果但全被过滤掉了(双重过滤导致),则不降级——返回空结果,不兜底。因为"找到但全不相关"比"假装找到了"更诚实,也避免 LLM 拿到无关上下文后胡编乱造。

7. 文档摄入:分词策略与 Milvus BM25 自动入库

7.1 上传与文本提取

文档上传通过 REST 接口:

POST /api/rag/documents
Content-Type: multipart/form-data

file: 产品手册.pdf
documentId: product_manual_v2
documentSource: product_kb

后端使用 Apache Tika 做文本提取,支持 PDF、DOCX、XLSX、HTML 等常见格式:

String rawText = TikaTextExtractor.extract(inputStream, filename);

7.2 两种切片策略

DocumentIngestService 提供两种切片策略,通过 jmyzt.rag.ingest.strategy 配置:

FIXED_LENGTH(固定长度滑动窗口)

int maxSize = 1200;   // 每块最多 1200 字符
int overlap = 150;    // 块间重叠 150 字符

int start = 0;
while (start < text.length()) {
    int end = Math.min(text.length(), start + maxSize);
    String chunk = text.substring(start, end).strip();
    start = Math.max(end - overlap, start + 1);  // 避免死循环
}

适合结构规整的文档。1200 字符大约 400-600 个中文字,是一个比较适中的上下文窗口。

PARAGRAPH_THEN_FIXED(先按段落切,长段落再固定切)

// 先按空行切段落
String[] paragraphs = text.split("\\n\\s*\\n+");
for (String para : paragraphs) {
    if (para.length() <= maxChunkChars) {
        chunks.add(para);  // 短段落直接作为一个 chunk
    } else {
        // 长段落递归按固定长度切
        splitLongParagraphFixedRecursive(para, chunks);
    }
}

适合有明确段落结构的产品技术文档。保留了文档的自然段落边界,检索时上下文的连贯性更好。

7.3 解决 Spring AI v1 InsertParam 与 BM25 函数字段的兼容问题

这是整个项目最"工程化"的一个问题。

Milvus 的 BM25 函数字段(FunctionType.BM25)是由服务端从 content 字段自动生成的。在插入数据时,不应该手动给这个字段赋值。

但 Spring AI 1.x 的 MilvusVectorStore 使用的是 Milvus v1 gRPC 客户端(InsertParam),它会尝试给 schema 中定义的所有字段赋值。当遇到 sparse_bm25 字段时,Spring AI 没有对应的数据,就报错了:

The field: sparse_bm25 is not provided

解决方案:绕过 Spring AI,直接使用 MilvusClientV2.insert(),在构建插入请求时故意不包含 sparse_bm25 字段

// MilvusBm25V2DocumentWriter.insertDocuments()
for (Document doc : documents) {
    JsonObject row = new JsonObject();
    row.addProperty("id", doc.getId());
    row.addProperty("content", doc.getText());
    row.addProperty("metadata", metadataJson);
    row.add("embedding", embeddingArray);  // 手动嵌入
    // 注意:不添加 sparse_bm25 字段!
    // Milvus 服务端 BM25 函数会自动填充
    data.add(row);
}

InsertReq insertReq = InsertReq.builder()
    .collectionName(collectionName)
    .data(data)
    .build();

milvusClientV2.insert(insertReq);

嵌入向量的生成也是手动调 EmbeddingModel.embed() 完成的,绕开了 Spring AI 的自动嵌入流程。

7.4 Collection Schema 自动建表

MilvusBm25CollectionProvisioner 在应用启动时自动执行,支持三种模式(milvus-bm25-collection-bootstrap 配置):

模式行为
none不做任何操作,假设集合已存在
create-if-missing如果集合不存在则创建(生产推荐)
recreate删除已有集合并重建(数据丢失! 仅开发用)

创建的集合 schema 包含:

字段名类型说明
idVarChar(36)主键
contentVarChar(65535)文本内容,Chinese analyzer 分词
metadataJSON文档元信息(documentId, documentName 等)
embeddingFloatVector(1536)文本嵌入向量
sparse_bm25SparseFloatVectorBM25 函数输出字段

BM25 函数定义:

schema.addFunction(CreateCollectionReq.Function.builder()
    .name("content_bm25")
    .functionType(FunctionType.BM25)
    .inputFieldNames(List.of("content"))       // 输入:文本字段
    .outputFieldNames(List.of("sparse_bm25"))  // 输出:稀疏向量字段
    .build());

BM25 索引参数:

Map<String, Object> sparseParams = new HashMap<>();
sparseParams.put("inverted_index_algo", "DAAT_MAXSCORE");
sparseParams.put("bm25_k1", 1.2);    // 词频饱和度参数
sparseParams.put("bm25_b", 0.75);    // 文档长度归一化参数

k1=1.2 和 b=0.75 是 Okapi BM25 的标准默认值。在这个中文长文档场景中我们没有做特别调整,因为标准参数对于中文长文档通常表现不错。

索引就绪等待:创建集合后,启动时会轮询 describeIndex 等待索引构建完成(最多 120 次 × 500ms = 60 秒),避免索引未就绪时查询返回空结果。

8. 踩过的坑与调参经验

8.1 向量池和 BM25 池不宜过大

最开始我们设向量池 100、BM25 池 100,觉得"多召回一些,反正后续有 rerank 筛选"。结果:

  • Milvus 检索时间从几十毫秒飙到几百毫秒
  • Rerank 阶段需要处理近 200 条候选,单次 rerank 调用耗时 3-5 秒
  • 大部分候选文档的分数很低,徒增延迟

现在稳定在向量池 40、BM25 池 24、rerank 候选上限 48。实测延迟降到 1.2 秒以内(全链路)。

原则:多路检索的池大小是"够用就好",不要贪多。

8.2 Rerank 后的绝对分过滤比相对分过滤更重要

一开始我们只设置了相对分过滤(minRelativeRetrievalScore=0.8),没有绝对分过滤。发现在某些边缘 query 上——用户问"今天天气怎么样"——Rerank 返回的所有文档分数都很低(~0.1),归一化后最高分也只有 0.1,相对分过滤形同虚设(0.1/0.1=1.0,全通过)。

加上 minRerankAbsoluteScore=0.3 后,这种情况会被正确拦截——所有 rerank 分低于 0.3 的文档直接丢弃,返回空列表,LLM 至少知道"没有找到相关文档"而不是对着无关内容瞎编。

8.3 Spring AI 版本不匹配问题

Spring AI 1.1.2 的 Milvus 集成基于 v1 gRPC 客户端,而 Milvus 2.5 的部分功能(EmbeddedText、BM25 函数)只在 v2 gRPC 中可用。这导致了两个 API 路径共存:

  • 向量检索:走 Spring AI(v1)
  • BM25 检索:直接调 MilvusClientV2(v2)
  • 文档写入:绕开 Spring AI,用自定义的 v2 writer

教训:框架的封装层不一定总是够用。对于前沿功能(如 Milvus 2.5 内置 BM25),要做好绕过框架直接调底层 SDK 的准备。 我们做了封装隔离——只在底层交互类中依赖 v2 SDK,上层业务逻辑仍然通过统一接口调用。

8.4 Query 改写的扩展词要有"节制"

最开始我们给每条规则加了很多扩展词,比如"故障"触发词映射到十几个扩展词。结果某些 query 改写得非常长(几百字),反而稀释了核心关键词的信号。

现在的策略是每条规则 3-5 个精准的扩展词。宁可少写,不要多写。

8.5 RRF 的 k 值调优

k=60 不是拍脑袋定的。我们在开发环境做了小规模对比:

k 值效果
k=0极端重视高 rank,排第一的文档权重极大。结果:几乎退化为"取两个列表的交集排序",单路表现差的 query 全挂
k=60当前的平衡点,共识文档有优势,但单路高排名的文档也不会被完全埋没
k=∞所有 rank 权重相同,退化为"在两个列表中都出现的排前面,都没出现的随机排"

k=60 在开发和测试阶段的表现稳定,所以沿用到了生产。

8.6 中文分词的 BM25 分析器选择

Milvus 的 Chinese analyzer 对中文文本的处理包括分词和停用词过滤。我们发现通用 Chinese analyzer 对垂直领域的专业术语分词效果有一些偏差——例如 “产品A系列” 可能被切为 “产品” + “A” + “系列”,“部件故障” 可能被切为 “部件” + “故障”。

目前通过在 query 改写阶段追加完整的专业词汇作为缓解手段(即使分析器误切,也能通过扩展后的完整词匹配到),后续考虑使用自定义词典来进一步优化。

9. 总结与下一步

9.1 这套方案的核心价值

  1. 意图优先,RAG 按需触发:不是所有请求都需要全套检索,两阶段 pipeline 在延迟和效果之间取得了好的平衡。
  2. 向量 + BM25 混合检索:单一数据库(Milvus 2.5 内置 BM25)搞定,不需要维护多个存储引擎。
  3. 规则驱动的 query 改写:领域知识显式编码,可调试、可维护,延迟为零。
  4. 多层过滤漏斗:从向量相似度阈值 → BM25 分过滤 → RRF 融合 → Rerank 双重过滤,每层砍掉一部分噪声,最终喂给 LLM 的是高度相关的上下文。
  5. 后处理门控:答案与用户实际情况交叉校验,降低风险。

9.2 当前局限与改进方向

  • Query 改写依赖规则维护:新增产品线时需要手动加规则。未来可以考虑用 LLM 生成候选扩展词 + 人工审核的半自动化流程。
  • BM25 分词精度:通用 Chinese analyzer 对领域专业术语的分词有偏差,可以引入自定义词典。
  • Rerank 是唯一的外部依赖:如果 DashScope Rerank 服务不可用,系统虽有降级策略但排序质量会下降。考虑评估本地 Cross-Encoder 模型作为备选。
  • 评估体系缺失:目前靠人工抽样评估检索质量。下一步需要构建标注数据集和自动化评估

9.3 关键配置速查

配置项用途
hybrid.vector-pool-top-k40向量召回候选数
hybrid.keyword-pool-top-k24BM25 召回候选数
hybrid.rrf-k60RRF 阻尼常数
hybrid.rerank-candidate-max48Rerank 输入上限
hybrid.min-vector-similarity0.15向量相似度阈值
hybrid.min-bm25-absolute-score1.0BM25 绝对分阈值
hybrid.min-relative-retrieval-score0.8Rerank 相对分阈值
hybrid.min-rerank-absolute-score0.3Rerank 绝对分阈值
rag.ingest.max-chunk-chars1200切片大小
rag.ingest.overlap-chars150切片重叠量

以上就是基于Spring AI+Milvus的RAG混合检索的实战指南的详细内容,更多关于Spring AI Milvus RAG混合检索的资料请关注脚本之家其它相关文章!

相关文章

  • Java可变个数形参的方法实例代码

    Java可变个数形参的方法实例代码

    这篇文章主要给大家介绍了关于Java可变个数形参的相关资料,文中通过图文以及实例代码介绍的非常详细,对大家学习或者使用java具有一定的参考学习价值,需要的朋友可以参考下
    2022-02-02
  • SVN出现提示org.apache.subversion.javahl.ClientException: Attempted to lock an already-locked dir解决方案

    SVN出现提示org.apache.subversion.javahl.ClientException: Attempt

    这篇文章主要介绍了SVN出现提示org.apache.subversion.javahl.ClientException: Attempted to lock an already-locked dir解决方案的相关资料,需要的朋友可以参考下
    2016-12-12
  • springboot-rabbitmq-reply 消息直接回复模式详情

    springboot-rabbitmq-reply 消息直接回复模式详情

    这篇文章主要介绍了springboot-rabbitmq-reply消息直接回复模式详情,文章通过围绕主题展开详细的内容介绍,具有一定的参考价值,感兴趣的小伙伴可以参考一下
    2022-09-09
  • Java matches类,Pattern类及matcher类用法示例

    Java matches类,Pattern类及matcher类用法示例

    这篇文章主要介绍了Java matches类,Pattern类及matcher类用法,结合实例形式分析了java matches类,Pattern类及matcher类针对字符串常见操作技巧与相关注意事项,需要的朋友可以参考下
    2019-03-03
  • SpringBoot详细讲解如何创建及刷新Spring容器bean

    SpringBoot详细讲解如何创建及刷新Spring容器bean

    前面看spring源码时可以发现refresh()方法十分重要。在这个方法中会加载beanDefinition,同时创建bean对象。那么在springboot中有没有使用这个refresh()方法呢
    2022-06-06
  • Java StringBuilder 实现原理全攻略

    Java StringBuilder 实现原理全攻略

    StringBuilder 是 Java 提供的可变字符序列类,位于 java.lang 包中,专门用于高效处理字符串的拼接和修改操作,本文给大家介绍Java StringBuilder 实现原理深度解析,感兴趣的朋友跟随小编一起看看吧
    2025-09-09
  • 关于@Entity和@Table注解的用法详解

    关于@Entity和@Table注解的用法详解

    这篇文章主要介绍了关于@Entity和@Table注解的用法详解,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-01-01
  • 深入理解java中i++和++i的区别

    深入理解java中i++和++i的区别

    下面小编就为大家带来一篇深入理解java中i++和++i的区别。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-12-12
  • Java中Connection timed out和Connection refused的区别讲解

    Java中Connection timed out和Connection refused的区别讲解

    今天小编就为大家分享一篇关于Java中Connection timed out和Connection refused的区别讲解,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧
    2019-04-04
  • java转化为exe程序步骤详解

    java转化为exe程序步骤详解

    在本篇内容里我们给大家分享了关于java转化为exe程序的具体步骤和相关知识点,需要的朋友们学习下。
    2019-03-03

最新评论