基于Spring AI+Milvus的RAG混合检索的实战指南
1. 背景与动机
1.1 业务场景
我们要为一个垂直业务平台构建智能客服助手。用户描述自己遇到的产品问题——“产品A 无法启动怎么排查”、“产品B 多久保养一次”——系统需要给出准确的解决方案。
数据侧,我们已经整理了大量的产品技术文档(PDF、DOCX),按产品线(产品A、产品B、产品C、产品D、产品E)分类。现在需要一套检索增强生成(RAG)系统,把这些文档"喂"给 LLM,生成结构化的解决方案。
1.2 核心挑战
直接做一个"用户问 → 向量检索 → LLM 回答"的朴素 RAG 够用吗?不够。
实际场景中我们面临几个关键问题:
- 意图多样:不是所有问题都需要 RAG。“你好”、"我之前提交的工单怎样了"这类不需要检索,直接走 LLM 或者查数据库即可。每次都触发全套检索链路是浪费。
- 精准召回难:纯向量检索对专业术语的敏感性有限。"产品C 运行异常"和"产品C 系统故障"在语义空间里可能很近,但纯关键词匹配可能漏掉。
- 检索噪声:召回 40 条候选里,可能有 50% 以上和问题无关。直接塞给 LLM 不仅浪费 token,还容易让 LLM 被噪声带偏。
- 领域术语差异:用户说"设备发热",文档写的是"机身温度过高";用户说"接口松动",文档写的是"连接端口接触不良"。不做 query 扩展根本搜不到。
1.3 选型
| 组件 | 选型 | 理由 |
|---|---|---|
| 框架 | Spring Boot 3.4.5 + Spring AI 1.1.2 | Java 生态成熟,Spring AI 封装了向量存储和 LLM 调用 |
| 向量数据库 | Milvus 2.5.6 | 原生支持 BM25 内置函数,免去额外 ES 依赖 |
| 嵌入模型 | DashScope text-embedding-v2 | 1536 维,中文效果好 |
| LLM | DashScope Qwen | 两阶段调用(意图分类 + 答案生成),同一模型不同 system prompt |
| Rerank | DashScope 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, RagController | REST 接口 |
| 核心 pipeline | DoctorAssistantService | 两阶段 LLM pipeline |
| RAG 检索 | RagQueryService | 混合检索编排 |
| Query 改写 | RagQueryRewriteService | 同义扩展 + 领域词映射 |
| 文档摄入 | DocumentIngestService | 文档上传、文本提取、切片 |
| Milvus BM25 | MilvusBuiltInBm25KeywordSearch | Milvus 内置 BM25 全文检索 |
| Milvus 建表 | MilvusBm25CollectionProvisioner | BM25 集合 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_CONSULT | LLM 直接回答,可选同步到 QA 库 | 低 |
VIEW_RECENT_FOLLOWUP_TASKS | 调用外部用户服务查历史数据 | 低 |
FIND_COMPONENT | 触发完整 RAG pipeline → LLM 生成解决方案 | 高 |
只有 FIND_COMPONENT 一个意图会触发 RAG。而且这里还有一个前置校验——通过外部服务确认用户确实关联了某种产品配置。如果用户没有相关产品却问"产品A 故障怎么办",系统不会浪费时间去检索文档。
3.3 后处理门控
即使 RAG 召回了文档,LLM 生成了回答,还有一个后处理门控:
- LLM 返回的答案中包含
componentCode(产品类型编码) - 系统检查这个编码是否与用户实际关联的产品类型一致
- 如果不一致——比如用户关联的是产品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();
关键设计点:
- 使用
EmbeddedText而非手动 BM25 向量化:把原始查询文本发给 Milvus,让服务端用建库时相同的 Chinese analyzer 分词后做 BM25 匹配。这保证了词法对齐——入库分析和查询分析用的同一套 tokenizer。 - 词法池取
max(topK, 24)条:比向量池的 40 小,因为实际场景中查询词一般较短(一两句话),BM25 能有效匹配的文档数本来就有限。 - BM25 绝对分过滤:我们加了一个
minBm25AbsoluteScore=1.0的阈值。BM25 分数低于 1.0 的文档基本没有有意义的词法匹配,属于噪声。 - 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 包含:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | VarChar(36) | 主键 |
content | VarChar(65535) | 文本内容,Chinese analyzer 分词 |
metadata | JSON | 文档元信息(documentId, documentName 等) |
embedding | FloatVector(1536) | 文本嵌入向量 |
sparse_bm25 | SparseFloatVector | BM25 函数输出字段 |
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 这套方案的核心价值
- 意图优先,RAG 按需触发:不是所有请求都需要全套检索,两阶段 pipeline 在延迟和效果之间取得了好的平衡。
- 向量 + BM25 混合检索:单一数据库(Milvus 2.5 内置 BM25)搞定,不需要维护多个存储引擎。
- 规则驱动的 query 改写:领域知识显式编码,可调试、可维护,延迟为零。
- 多层过滤漏斗:从向量相似度阈值 → BM25 分过滤 → RRF 融合 → Rerank 双重过滤,每层砍掉一部分噪声,最终喂给 LLM 的是高度相关的上下文。
- 后处理门控:答案与用户实际情况交叉校验,降低风险。
9.2 当前局限与改进方向
- Query 改写依赖规则维护:新增产品线时需要手动加规则。未来可以考虑用 LLM 生成候选扩展词 + 人工审核的半自动化流程。
- BM25 分词精度:通用 Chinese analyzer 对领域专业术语的分词有偏差,可以引入自定义词典。
- Rerank 是唯一的外部依赖:如果 DashScope Rerank 服务不可用,系统虽有降级策略但排序质量会下降。考虑评估本地 Cross-Encoder 模型作为备选。
- 评估体系缺失:目前靠人工抽样评估检索质量。下一步需要构建标注数据集和自动化评估
9.3 关键配置速查
| 配置项 | 值 | 用途 |
|---|---|---|
hybrid.vector-pool-top-k | 40 | 向量召回候选数 |
hybrid.keyword-pool-top-k | 24 | BM25 召回候选数 |
hybrid.rrf-k | 60 | RRF 阻尼常数 |
hybrid.rerank-candidate-max | 48 | Rerank 输入上限 |
hybrid.min-vector-similarity | 0.15 | 向量相似度阈值 |
hybrid.min-bm25-absolute-score | 1.0 | BM25 绝对分阈值 |
hybrid.min-relative-retrieval-score | 0.8 | Rerank 相对分阈值 |
hybrid.min-rerank-absolute-score | 0.3 | Rerank 绝对分阈值 |
rag.ingest.max-chunk-chars | 1200 | 切片大小 |
rag.ingest.overlap-chars | 150 | 切片重叠量 |
以上就是基于Spring AI+Milvus的RAG混合检索的实战指南的详细内容,更多关于Spring AI Milvus RAG混合检索的资料请关注脚本之家其它相关文章!
相关文章
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消息直接回复模式详情,文章通过围绕主题展开详细的内容介绍,具有一定的参考价值,感兴趣的小伙伴可以参考一下2022-09-09
Java matches类,Pattern类及matcher类用法示例
这篇文章主要介绍了Java matches类,Pattern类及matcher类用法,结合实例形式分析了java matches类,Pattern类及matcher类针对字符串常见操作技巧与相关注意事项,需要的朋友可以参考下2019-03-03
SpringBoot详细讲解如何创建及刷新Spring容器bean
前面看spring源码时可以发现refresh()方法十分重要。在这个方法中会加载beanDefinition,同时创建bean对象。那么在springboot中有没有使用这个refresh()方法呢2022-06-06
Java中Connection timed out和Connection refused的区别讲解
今天小编就为大家分享一篇关于Java中Connection timed out和Connection refused的区别讲解,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧2019-04-04


最新评论