基于Spring AI+Milvus的RAG混合檢索的實(shí)戰(zhàn)指南
1. 背景與動(dòng)機(jī)
1.1 業(yè)務(wù)場(chǎng)景
我們要為一個(gè)垂直業(yè)務(wù)平臺(tái)構(gòu)建智能客服助手。用戶描述自己遇到的產(chǎn)品問題——“產(chǎn)品A 無法啟動(dòng)怎么排查”、“產(chǎn)品B 多久保養(yǎng)一次”——系統(tǒng)需要給出準(zhǔn)確的解決方案。
數(shù)據(jù)側(cè),我們已經(jīng)整理了大量的產(chǎn)品技術(shù)文檔(PDF、DOCX),按產(chǎn)品線(產(chǎn)品A、產(chǎn)品B、產(chǎn)品C、產(chǎn)品D、產(chǎn)品E)分類。現(xiàn)在需要一套檢索增強(qiáng)生成(RAG)系統(tǒng),把這些文檔"喂"給 LLM,生成結(jié)構(gòu)化的解決方案。
1.2 核心挑戰(zhàn)
直接做一個(gè)"用戶問 → 向量檢索 → LLM 回答"的樸素 RAG 夠用嗎?不夠。
實(shí)際場(chǎng)景中我們面臨幾個(gè)關(guān)鍵問題:
- 意圖多樣:不是所有問題都需要 RAG。“你好”、"我之前提交的工單怎樣了"這類不需要檢索,直接走 LLM 或者查數(shù)據(jù)庫即可。每次都觸發(fā)全套檢索鏈路是浪費(fèi)。
- 精準(zhǔn)召回難:純向量檢索對(duì)專業(yè)術(shù)語的敏感性有限。"產(chǎn)品C 運(yùn)行異常"和"產(chǎn)品C 系統(tǒng)故障"在語義空間里可能很近,但純關(guān)鍵詞匹配可能漏掉。
- 檢索噪聲:召回 40 條候選里,可能有 50% 以上和問題無關(guān)。直接塞給 LLM 不僅浪費(fèi) token,還容易讓 LLM 被噪聲帶偏。
- 領(lǐng)域術(shù)語差異:用戶說"設(shè)備發(fā)熱",文檔寫的是"機(jī)身溫度過高";用戶說"接口松動(dòng)",文檔寫的是"連接端口接觸不良"。不做 query 擴(kuò)展根本搜不到。
1.3 選型
| 組件 | 選型 | 理由 |
|---|---|---|
| 框架 | Spring Boot 3.4.5 + Spring AI 1.1.2 | Java 生態(tài)成熟,Spring AI 封裝了向量存儲(chǔ)和 LLM 調(diào)用 |
| 向量數(shù)據(jù)庫 | Milvus 2.5.6 | 原生支持 BM25 內(nèi)置函數(shù),免去額外 ES 依賴 |
| 嵌入模型 | DashScope text-embedding-v2 | 1536 維,中文效果好 |
| LLM | DashScope Qwen | 兩階段調(diào)用(意圖分類 + 答案生成),同一模型不同 system prompt |
| Rerank | DashScope Rerank | 與 LLM 同供應(yīng)商,延遲可控 |
2. 整體架構(gòu)概覽
整個(gè)問答系統(tǒng)的核心 pipeline 分為兩階段:
┌─────────────────────────────────────────────────────────┐
│ 用戶輸入(問題文本) │
└─────────────────────┬───────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ Phase 1: 意圖分類(輕量 LLM 調(diào)用) │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │GENERAL │ │VIEW_RECENT │ │FIND │ │
│ │_CONSULT │ │_FOLLOWUP_TASKS │ │_COMPONENT │ │
│ │一般咨詢 │ │查看歷史記錄 │ │查找解決方案 │ │
│ └──────┬──────┘ └────────┬─────────┘ └─────┬──────┘ │
└─────────┼──────────────────┼──────────────────┼────────┘
│ │ │
▼ ▼ ▼
LLM 直接回復(fù) 查數(shù)據(jù)庫返回 觸發(fā) Phase 2
(可選同步 QA 庫) 歷史記錄 RAG 檢索鏈路
│
▼
┌─────────────────────────────────┐
│ Phase 2: RAG 混合檢索 │
│ │
│ Query 改寫 → 向量檢索 + BM25 │
│ → RRF 融合 → Rerank │
│ → 后處理門控 → LLM 生成 │
└─────────────────────────────────┘
關(guān)鍵設(shè)計(jì)決策:意圖優(yōu)先,RAG 按需觸發(fā)。只有 FIND_COMPONENT 意圖才走完整的混合檢索鏈路,其他意圖直接走輕型路徑。這樣做的好處是:
- 減少不必要的 LLM 調(diào)用次數(shù)(Phase 1 和 Phase 2 各自只調(diào)一次 LLM)
- 降低平均延遲(大部分請(qǐng)求不需要 RAG)
- Phase 1 分類 + Phase 2 生成的兩次 LLM 調(diào)用可針對(duì)各自任務(wù)分別調(diào)參
核心服務(wù)類關(guān)系:
| 層級(jí) | 關(guān)鍵類 | 職責(zé) |
|---|---|---|
| Web 層 | AssistantController, RagController | REST 接口 |
| 核心 pipeline | DoctorAssistantService | 兩階段 LLM pipeline |
| RAG 檢索 | RagQueryService | 混合檢索編排 |
| Query 改寫 | RagQueryRewriteService | 同義擴(kuò)展 + 領(lǐng)域詞映射 |
| 文檔攝入 | DocumentIngestService | 文檔上傳、文本提取、切片 |
| Milvus BM25 | MilvusBuiltInBm25KeywordSearch | Milvus 內(nèi)置 BM25 全文檢索 |
| Milvus 建表 | MilvusBm25CollectionProvisioner | BM25 集合 schema 自動(dòng)創(chuàng)建 |
| Milvus v2 寫入 | MilvusBm25V2DocumentWriter | 適配 BM25 函數(shù)字段的數(shù)據(jù)寫入 |
3. 意圖路由:輕量分類 + RAG 按需觸發(fā)
3.1 方案選型
在要不要先做意圖分類這個(gè)問題上,我們的權(quán)衡是這樣的:
- 不做分類,直接 RAG:簡(jiǎn)單粗暴,但每個(gè)"你好"都要跑一遍檢索鏈路,浪費(fèi)算力和延遲。
- 兩階段 LLM:Phase 1 用輕量 prompt 做分類,Phase 2 只在需要時(shí)才觸發(fā)。
我們選擇了后者。Phase 1 的意圖分類 prompt 要求 LLM 返回嚴(yán)格的 JSON:
你是產(chǎn)品技術(shù)支持領(lǐng)域的意圖識(shí)別專家。根據(jù)用戶問題,識(shí)別意圖類型。
必須嚴(yán)格返回JSON格式:
{"intentType": "GENERAL_CONSULT"|"FIND_COMPONENT"|"VIEW_RECENT_FOLLOWUP_TASKS"}
這個(gè)階段 LLM 的推理負(fù)擔(dān)極輕——不需要理解全文,不需要檢索上下文,只需要判斷用戶想干什么。
3.2 三種意圖的處理路徑
| 意圖 | 處理方式 | 延遲 |
|---|---|---|
GENERAL_CONSULT | LLM 直接回答,可選同步到 QA 庫 | 低 |
VIEW_RECENT_FOLLOWUP_TASKS | 調(diào)用外部用戶服務(wù)查歷史數(shù)據(jù) | 低 |
FIND_COMPONENT | 觸發(fā)完整 RAG pipeline → LLM 生成解決方案 | 高 |
只有 FIND_COMPONENT 一個(gè)意圖會(huì)觸發(fā) RAG。而且這里還有一個(gè)前置校驗(yàn)——通過外部服務(wù)確認(rèn)用戶確實(shí)關(guān)聯(lián)了某種產(chǎn)品配置。如果用戶沒有相關(guān)產(chǎn)品卻問"產(chǎn)品A 故障怎么辦",系統(tǒng)不會(huì)浪費(fèi)時(shí)間去檢索文檔。
3.3 后處理門控
即使 RAG 召回了文檔,LLM 生成了回答,還有一個(gè)后處理門控:
- LLM 返回的答案中包含
componentCode(產(chǎn)品類型編碼) - 系統(tǒng)檢查這個(gè)編碼是否與用戶實(shí)際關(guān)聯(lián)的產(chǎn)品類型一致
- 如果不一致——比如用戶關(guān)聯(lián)的是產(chǎn)品B,但 RAG 召回了產(chǎn)品A 的相關(guān)文檔——系統(tǒng)將此回答降級(jí)為
GENERAL_CONSULT,即只給一般性建議,不給具體操作指引
這個(gè)門控很重要,因?yàn)樵跇I(yè)務(wù)場(chǎng)景下,給錯(cuò)產(chǎn)品線的操作建議比不給更危險(xiǎn)。
4. 混合檢索 pipeline:向量 + BM25 + RRF 融合
混合檢索是 RAG 系統(tǒng)的核心。我們來詳細(xì)拆解每一步。
4.1 為什么要混合檢索
純向量檢索(dense retrieval)的問題是:
- 向量相似 ≠ 關(guān)鍵詞匹配。"產(chǎn)品C 故障"和"產(chǎn)品C 運(yùn)行異常"語義相近,但用戶搜索"產(chǎn)品C"這個(gè)詞時(shí),向量檢索不一定能準(zhǔn)確區(qū)分"產(chǎn)品C"和"產(chǎn)品A系列"的文檔。
- 對(duì)稀有詞、專業(yè)名詞的召回容易漂移。向量模型在訓(xùn)練時(shí)可能沒見過足夠的該領(lǐng)域語料。
純關(guān)鍵詞檢索(BM25 sparse retrieval)的問題是:
- 詞匯不匹配就沒結(jié)果。用戶說"設(shè)備發(fā)熱",BM25 搜不到寫了"機(jī)身溫度過高"的文檔。
- 無法理解語義。"產(chǎn)品A 卡頓"和"設(shè)備無響應(yīng)"沒有共同詞,BM25 得分是 0。
兩者結(jié)合,優(yōu)勢(shì)互補(bǔ)。
4.2 技術(shù)選型:Milvus 2.5 內(nèi)置 BM25
市面上常見的混合檢索方案通常需要兩個(gè)引擎:Milvus/Qdrant 做向量 + Elasticsearch 做 BM25。維護(hù)兩套索引,還要在兩個(gè)結(jié)果集之間做融合,架構(gòu)復(fù)雜度不小。
Milvus 2.5 開始支持 內(nèi)置 BM25——通過 FunctionType.BM25 在 schema 中定義一個(gè)函數(shù),將文本字段自動(dòng)轉(zhuǎn)換為稀疏向量。檢索時(shí)用 EmbeddedText 將查詢文本發(fā)給 Milvus,服務(wù)端自動(dòng)分詞并計(jì)算 BM25 分?jǐn)?shù)。
好處很明顯:
- 單一數(shù)據(jù)庫,不需要維護(hù) ES 集群
- 向量檢索和 BM25 檢索可以復(fù)用同一套過濾條件(documentId、documentSource)
- 部署簡(jiǎn)單
代價(jià)是:
- BM25 參數(shù)調(diào)優(yōu)受限于 Milvus 的暴露程度(我們可以在建索引時(shí)配置 k1、b,但運(yùn)行時(shí)不可動(dòng)態(tài)調(diào)整)
- 分詞依賴 Milvus 內(nèi)置的 analyzer(我們用的是 Chinese analyzer)
4.3 檢索 pipeline 代碼拆解
核心入口在 RagQueryService.retrieveForRag():
用戶問題
│
▼
queryRewriteService
.rewriteForRetrieval() ← 領(lǐng)域改寫(見第5節(jié))
│
▼
┌─────────────┴─────────────┐
│ hybrid enabled? │
└─────────────┬─────────────┘
Yes │ No
┌─────────────┴─────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 并行雙路檢索 │ │ 純向量檢索 │
│ │ │ topK=N │
│ 向量池: 40條 │ │ minSim=0.15 │
│ BM25池: 24條 │ └──────────────┘
└──────┬───────┘
▼
RRF 融合 (k=60)
│
▼
截?cái)嗟?max(topK, 48)
│
▼
DashScope Rerank
│
▼
多重過濾 → 返回 topK
如果 hybrid.enabled=false,整個(gè)鏈路退化為純向量檢索,用于做對(duì)照實(shí)驗(yàn)。
4.4 向量檢索
使用 Spring AI 封裝的 Milvus 向量存儲(chǔ),配置 IVF_FLAT 索引 + COSINE 相似度:
MilvusSearchRequest request = MilvusSearchRequest.milvusBuilder()
.query(retrievalQuery)
.topK(topK)
.searchParamsJson("{\"nprobe\":128}")
.similarityThreshold(0.15) // 過濾低分噪聲
.build();
return milvusVectorStore.similaritySearch(request);
幾個(gè)要點(diǎn):
nprobe=128:IVF_FLAT 索引搜索時(shí)掃描 128 個(gè)最近的聚類中心,在召回率和速度間取平衡similarityThreshold=0.15:COSINE 相似度低于 0.15 的直接丟棄,減少噪聲- 向量池取
max(topK, 40)條——即使請(qǐng)求只需要 5 條,也要多召回一些給后續(xù)融合留空間
4.5 Milvus BM25 檢索
BM25 檢索通過 MilvusBuiltInBm25KeywordSearch 實(shí)現(xiàn),直接調(diào)用 Milvus v2 gRPC API:
SearchReq.builder()
.collectionName(collectionName)
.annsField("sparse_bm25") // 稀疏向量字段名
.data(List.of(new EmbeddedText(query))) // 服務(wù)端自動(dòng)分詞
.topK(topK)
.metricType(IndexMetricType.BM25)
.outputFields(List.of("id", "content", "metadata"))
.filter(filter) // 與向量檢索一致的過濾條件
.build();
關(guān)鍵設(shè)計(jì)點(diǎn):
- 使用
EmbeddedText而非手動(dòng) BM25 向量化:把原始查詢文本發(fā)給 Milvus,讓服務(wù)端用建庫時(shí)相同的 Chinese analyzer 分詞后做 BM25 匹配。這保證了詞法對(duì)齊——入庫分析和查詢分析用的同一套 tokenizer。 - 詞法池取
max(topK, 24)條:比向量池的 40 小,因?yàn)閷?shí)際場(chǎng)景中查詢?cè)~一般較短(一兩句話),BM25 能有效匹配的文檔數(shù)本來就有限。 - BM25 絕對(duì)分過濾:我們加了一個(gè)
minBm25AbsoluteScore=1.0的閾值。BM25 分?jǐn)?shù)低于 1.0 的文檔基本沒有有意義的詞法匹配,屬于噪聲。 - BM25 相對(duì)分過濾:同一批次內(nèi),
BM25_score / max(BM25_score)低于 0.8 的丟棄。這個(gè)閾值可以配置。
4.6 RRF 融合
Reciprocal Rank Fusion(RRF)是一種簡(jiǎn)潔有效的分?jǐn)?shù)融合方法:
// 偽代碼
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 是阻尼常數(shù)。
為什么用 RRF 而不是簡(jiǎn)單的分?jǐn)?shù)歸一化?
傳統(tǒng)分?jǐn)?shù)融合需要做 score normalization——比如 min-max 歸一化。但問題是:
- 向量相似度和 BM25 分?jǐn)?shù)的分布完全不同,線性歸一化的假設(shè)不成立
- BM25 分?jǐn)?shù)理論上無上限,向量 COSINE 相似度在 -1 到 1 之間,歸一化后的"權(quán)重"很難解釋
RRF 不關(guān)心原始分?jǐn)?shù)的絕對(duì)值,只關(guān)心相對(duì)排名,天然適合異構(gòu)檢索結(jié)果的融合。
k 值的選擇:我們?cè)O(shè) rrfK=60。k 越大,排名差異的影響越小,更偏向于"同時(shí)出現(xiàn)在兩個(gè)列表中的文檔得分高"。k=60 意味著:
- 只在向量列表中排第 1 名的文檔:
1/(60+1) = 0.0164 - 在向量列表排第 10、BM25 列表排第 5 的文檔:
1/(60+10) + 1/(60+5) = 0.0143 + 0.0154 = 0.0297 - 同時(shí)在兩個(gè)列表排第 1 的文檔:
1/61 + 1/61 = 0.0328
可以看到,出現(xiàn)在兩個(gè)列表中的文檔 RRF 分明顯更高——這正是我們想要的:兩個(gè)檢索器"共識(shí)"的文檔排前面。
5. Query 改寫:領(lǐng)域詞擴(kuò)展與同義映射
5.1 為什么需要改寫
前面提到,用戶的自然語言和專業(yè)文檔的書面表述之間存在 gap。一個(gè)典型的例子:
| 用戶問題 | 文檔表述 |
|---|---|
| “設(shè)備發(fā)熱” | “機(jī)身溫度過高” |
| “接口松動(dòng)” | “連接端口接觸不良” |
| “多久換一次” | “配件更換周期/更換頻率” |
如果不做改寫,向量檢索和 BM25 檢索都可能漏掉真正相關(guān)的文檔。
5.2 改寫策略
RagQueryRewriteService.rewriteForRetrieval() 執(zhí)行三步處理:
Step 1: 文本歸一化(Normalize)
String normalized = original.trim().replaceAll("\\s+", " ");
最簡(jiǎn)單的清洗:去首尾空白、合并多余空格。
Step 2: 規(guī)則驅(qū)動(dòng)的詞擴(kuò)展
維護(hù)了一個(gè)包含 27 條規(guī)則的映射表,每條規(guī)則定義了一組觸發(fā)詞和對(duì)應(yīng)的擴(kuò)展詞:
rule(
new String[] {"保養(yǎng)周期", "保養(yǎng)頻率", "多久保養(yǎng)", "檢查周期",
"多長(zhǎng)時(shí)間檢測(cè)", "維護(hù)頻率", "多久檢查"},
new String[] {"常規(guī)保養(yǎng)間隔", "使用期間檢查頻率", "保養(yǎng)周期",
"安裝后維護(hù)", "常規(guī)保養(yǎng)"}
)
匹配邏輯是:如果用戶問題中出現(xiàn)任意一個(gè)觸發(fā)詞,就將所有擴(kuò)展詞(未在問題原文中出現(xiàn)的)追加到檢索 query 后面。
為什么用規(guī)則而不是 LLM 做改寫?
- 確定性:規(guī)則擴(kuò)寫的結(jié)果是可預(yù)測(cè)、可調(diào)試的。LLM 改寫可能引入不確定的語義漂移。
- 低延遲:規(guī)則匹配是 O(n) 字符串查找,毫秒級(jí)完成。LLM 改寫需要一次完整的推理,延遲 1-3 秒。
- 可維護(hù)性:新增一個(gè)領(lǐng)域詞映射只需要加一條規(guī)則,不需要重新訓(xùn)練或調(diào) prompt。
- 夠用:該業(yè)務(wù)領(lǐng)域的同義詞匯是有限的、可枚舉的?;ɡ锖诘?LLM 改寫是 overkill。
當(dāng)一個(gè)領(lǐng)域的同義詞是可枚舉且有限的,規(guī)則引擎就是最好的選擇。
Step 3: 產(chǎn)品上下文擴(kuò)展
如果檢測(cè)到問題涉及特定的產(chǎn)品線,自動(dòng)追加對(duì)應(yīng)術(shù)語:
if (question.contains("產(chǎn)品A") || question.contains("A系列")) {
expansionTerms.add("產(chǎn)品A");
expansionTerms.add("產(chǎn)品A系列(企業(yè)級(jí)高性能型號(hào))");
// 追加產(chǎn)品A相關(guān)的故障關(guān)鍵詞
expansionTerms.addAll(getKeywordsForProduct(PRODUCT_A));
}
最終,改寫后的檢索 query 是:
原始問題 + 擴(kuò)展詞1 擴(kuò)展詞2 擴(kuò)展詞3 ...
改寫 query 只用于檢索,不用于 LLM 生成。生成階段的 prompt 仍然使用用戶的原始問題原文。
6. DashScope Rerank 精排與多重過濾
6.1 為什么需要 Rerank
RRF 融合的結(jié)果是基于兩個(gè)檢索器各自排名的折中,但它不知道哪個(gè)文檔真正回答了用戶的問題。
Rerank 模型把每個(gè)候選文檔和用戶問題做一對(duì)一的語義相關(guān)性打分,比向量相似度的 rank 更精細(xì)。DashScope 的 Rerank 模型(gte-rerank)專門為這個(gè)任務(wù)優(yōu)化過。
6.2 Rerank 調(diào)用
RRF 融合后,候選列表先截?cái)嗟?max(topK, 48) 條,然后調(diào)用 DashScope Rerank:
RerankResponse response = rerankModel.call(new RerankRequest(
retrievalQuery,
fusedDocs, // candidate documents
DashScopeRerankOptions.builder()
.withTopN(topK)
.build()
));
注意這里的 retrievalQuery 是改寫后的 query(包含擴(kuò)展詞),不是原始問題。因?yàn)?Rerank 階段希望獲得更多的檢索信號(hào)來排序。
6.3 雙重過濾
Rerank 返回的結(jié)果還要經(jīng)過兩層過濾,而且是 AND 關(guān)系:
相對(duì)分過濾(batch-normalized):
double maxScore = results.stream()
.mapToDouble(RerankResult.Result::getRelevanceScore)
.max().orElse(1.0);
results.stream()
.filter(r -> r.getRelevanceScore() / maxScore >= 0.8)
.toList();
以同一批次中最高分為基準(zhǔn),相對(duì)分低于 0.8 的丟棄。這保證了返回的文檔與問題的相關(guān)性不低于"最佳匹配文檔"的 80%。
絕對(duì)分過濾:
results.stream()
.filter(r -> r.getRelevanceScore() >= 0.3)
.toList();
Rerank 分?jǐn)?shù)低于 0.3 表示文檔基本與問題無關(guān),直接扔掉。
兩重過濾的關(guān)系是 AND:一個(gè)文檔必須同時(shí)滿足相對(duì)分 >= 0.8×max 和絕對(duì)分 >= 0.3 才會(huì)被保留。
6.4 熱切換與降級(jí)
Rerank 是可熱切換的:
RerankModel rerank = hybridProperties.isUseRerankWhenAvailable()
? rerankModel.getIfAvailable() : null;
if (rerank == null) {
// 無 rerank,直接用 RRF 結(jié)果截?cái)喾祷?
return fused.stream().limit(topK).toList();
}
use-rerank-when-available 配置項(xiàng)設(shè)為 false 即可跳過 rerank,這對(duì)于壓測(cè)和故障降級(jí)很有用。
降級(jí)策略:如果 rerank 調(diào)用返回空結(jié)果,系統(tǒng)回退到 RRF 融合列表截?cái)?topK 輸出。但如果 rerank 返回了結(jié)果但全被過濾掉了(雙重過濾導(dǎo)致),則不降級(jí)——返回空結(jié)果,不兜底。因?yàn)?quot;找到但全不相關(guān)"比"假裝找到了"更誠(chéng)實(shí),也避免 LLM 拿到無關(guān)上下文后胡編亂造。
7. 文檔攝入:分詞策略與 Milvus BM25 自動(dòng)入庫
7.1 上傳與文本提取
文檔上傳通過 REST 接口:
POST /api/rag/documents Content-Type: multipart/form-data file: 產(chǎn)品手冊(cè).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(固定長(zhǎng)度滑動(dòng)窗口):
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); // 避免死循環(huán)
}
適合結(jié)構(gòu)規(guī)整的文檔。1200 字符大約 400-600 個(gè)中文字,是一個(gè)比較適中的上下文窗口。
PARAGRAPH_THEN_FIXED(先按段落切,長(zhǎng)段落再固定切):
// 先按空行切段落
String[] paragraphs = text.split("\\n\\s*\\n+");
for (String para : paragraphs) {
if (para.length() <= maxChunkChars) {
chunks.add(para); // 短段落直接作為一個(gè) chunk
} else {
// 長(zhǎng)段落遞歸按固定長(zhǎng)度切
splitLongParagraphFixedRecursive(para, chunks);
}
}
適合有明確段落結(jié)構(gòu)的產(chǎn)品技術(shù)文檔。保留了文檔的自然段落邊界,檢索時(shí)上下文的連貫性更好。
7.3 解決 Spring AI v1 InsertParam 與 BM25 函數(shù)字段的兼容問題
這是整個(gè)項(xiàng)目最"工程化"的一個(gè)問題。
Milvus 的 BM25 函數(shù)字段(FunctionType.BM25)是由服務(wù)端從 content 字段自動(dòng)生成的。在插入數(shù)據(jù)時(shí),不應(yīng)該手動(dòng)給這個(gè)字段賦值。
但 Spring AI 1.x 的 MilvusVectorStore 使用的是 Milvus v1 gRPC 客戶端(InsertParam),它會(huì)嘗試給 schema 中定義的所有字段賦值。當(dāng)遇到 sparse_bm25 字段時(shí),Spring AI 沒有對(duì)應(yīng)的數(shù)據(jù),就報(bào)錯(cuò)了:
The field: sparse_bm25 is not provided
解決方案:繞過 Spring AI,直接使用 MilvusClientV2.insert(),在構(gòu)建插入請(qǐng)求時(shí)故意不包含 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); // 手動(dòng)嵌入
// 注意:不添加 sparse_bm25 字段!
// Milvus 服務(wù)端 BM25 函數(shù)會(huì)自動(dòng)填充
data.add(row);
}
InsertReq insertReq = InsertReq.builder()
.collectionName(collectionName)
.data(data)
.build();
milvusClientV2.insert(insertReq);
嵌入向量的生成也是手動(dòng)調(diào) EmbeddingModel.embed() 完成的,繞開了 Spring AI 的自動(dòng)嵌入流程。
7.4 Collection Schema 自動(dòng)建表
MilvusBm25CollectionProvisioner 在應(yīng)用啟動(dòng)時(shí)自動(dòng)執(zhí)行,支持三種模式(milvus-bm25-collection-bootstrap 配置):
| 模式 | 行為 |
|---|---|
none | 不做任何操作,假設(shè)集合已存在 |
create-if-missing | 如果集合不存在則創(chuàng)建(生產(chǎn)推薦) |
recreate | 刪除已有集合并重建(數(shù)據(jù)丟失! 僅開發(fā)用) |
創(chuàng)建的集合 schema 包含:
| 字段名 | 類型 | 說明 |
|---|---|---|
id | VarChar(36) | 主鍵 |
content | VarChar(65535) | 文本內(nèi)容,Chinese analyzer 分詞 |
metadata | JSON | 文檔元信息(documentId, documentName 等) |
embedding | FloatVector(1536) | 文本嵌入向量 |
sparse_bm25 | SparseFloatVector | BM25 函數(shù)輸出字段 |
BM25 函數(shù)定義:
schema.addFunction(CreateCollectionReq.Function.builder()
.name("content_bm25")
.functionType(FunctionType.BM25)
.inputFieldNames(List.of("content")) // 輸入:文本字段
.outputFieldNames(List.of("sparse_bm25")) // 輸出:稀疏向量字段
.build());
BM25 索引參數(shù):
Map<String, Object> sparseParams = new HashMap<>();
sparseParams.put("inverted_index_algo", "DAAT_MAXSCORE");
sparseParams.put("bm25_k1", 1.2); // 詞頻飽和度參數(shù)
sparseParams.put("bm25_b", 0.75); // 文檔長(zhǎng)度歸一化參數(shù)
k1=1.2 和 b=0.75 是 Okapi BM25 的標(biāo)準(zhǔn)默認(rèn)值。在這個(gè)中文長(zhǎng)文檔場(chǎng)景中我們沒有做特別調(diào)整,因?yàn)闃?biāo)準(zhǔn)參數(shù)對(duì)于中文長(zhǎng)文檔通常表現(xiàn)不錯(cuò)。
索引就緒等待:創(chuàng)建集合后,啟動(dòng)時(shí)會(huì)輪詢 describeIndex 等待索引構(gòu)建完成(最多 120 次 × 500ms = 60 秒),避免索引未就緒時(shí)查詢返回空結(jié)果。
8. 踩過的坑與調(diào)參經(jīng)驗(yàn)
8.1 向量池和 BM25 池不宜過大
最開始我們?cè)O(shè)向量池 100、BM25 池 100,覺得"多召回一些,反正后續(xù)有 rerank 篩選"。結(jié)果:
- Milvus 檢索時(shí)間從幾十毫秒飆到幾百毫秒
- Rerank 階段需要處理近 200 條候選,單次 rerank 調(diào)用耗時(shí) 3-5 秒
- 大部分候選文檔的分?jǐn)?shù)很低,徒增延遲
現(xiàn)在穩(wěn)定在向量池 40、BM25 池 24、rerank 候選上限 48。實(shí)測(cè)延遲降到 1.2 秒以內(nèi)(全鏈路)。
原則:多路檢索的池大小是"夠用就好",不要貪多。
8.2 Rerank 后的絕對(duì)分過濾比相對(duì)分過濾更重要
一開始我們只設(shè)置了相對(duì)分過濾(minRelativeRetrievalScore=0.8),沒有絕對(duì)分過濾。發(fā)現(xiàn)在某些邊緣 query 上——用戶問"今天天氣怎么樣"——Rerank 返回的所有文檔分?jǐn)?shù)都很低(~0.1),歸一化后最高分也只有 0.1,相對(duì)分過濾形同虛設(shè)(0.1/0.1=1.0,全通過)。
加上 minRerankAbsoluteScore=0.3 后,這種情況會(huì)被正確攔截——所有 rerank 分低于 0.3 的文檔直接丟棄,返回空列表,LLM 至少知道"沒有找到相關(guān)文檔"而不是對(duì)著無關(guān)內(nèi)容瞎編。
8.3 Spring AI 版本不匹配問題
Spring AI 1.1.2 的 Milvus 集成基于 v1 gRPC 客戶端,而 Milvus 2.5 的部分功能(EmbeddedText、BM25 函數(shù))只在 v2 gRPC 中可用。這導(dǎo)致了兩個(gè) API 路徑共存:
- 向量檢索:走 Spring AI(v1)
- BM25 檢索:直接調(diào) MilvusClientV2(v2)
- 文檔寫入:繞開 Spring AI,用自定義的 v2 writer
教訓(xùn):框架的封裝層不一定總是夠用。對(duì)于前沿功能(如 Milvus 2.5 內(nèi)置 BM25),要做好繞過框架直接調(diào)底層 SDK 的準(zhǔn)備。 我們做了封裝隔離——只在底層交互類中依賴 v2 SDK,上層業(yè)務(wù)邏輯仍然通過統(tǒng)一接口調(diào)用。
8.4 Query 改寫的擴(kuò)展詞要有"節(jié)制"
最開始我們給每條規(guī)則加了很多擴(kuò)展詞,比如"故障"觸發(fā)詞映射到十幾個(gè)擴(kuò)展詞。結(jié)果某些 query 改寫得非常長(zhǎng)(幾百字),反而稀釋了核心關(guān)鍵詞的信號(hào)。
現(xiàn)在的策略是每條規(guī)則 3-5 個(gè)精準(zhǔn)的擴(kuò)展詞。寧可少寫,不要多寫。
8.5 RRF 的 k 值調(diào)優(yōu)
k=60 不是拍腦袋定的。我們?cè)陂_發(fā)環(huán)境做了小規(guī)模對(duì)比:
| k 值 | 效果 |
|---|---|
| k=0 | 極端重視高 rank,排第一的文檔權(quán)重極大。結(jié)果:幾乎退化為"取兩個(gè)列表的交集排序",單路表現(xiàn)差的 query 全掛 |
| k=60 | 當(dāng)前的平衡點(diǎn),共識(shí)文檔有優(yōu)勢(shì),但單路高排名的文檔也不會(huì)被完全埋沒 |
| k=∞ | 所有 rank 權(quán)重相同,退化為"在兩個(gè)列表中都出現(xiàn)的排前面,都沒出現(xiàn)的隨機(jī)排" |
k=60 在開發(fā)和測(cè)試階段的表現(xiàn)穩(wěn)定,所以沿用到了生產(chǎn)。
8.6 中文分詞的 BM25 分析器選擇
Milvus 的 Chinese analyzer 對(duì)中文文本的處理包括分詞和停用詞過濾。我們發(fā)現(xiàn)通用 Chinese analyzer 對(duì)垂直領(lǐng)域的專業(yè)術(shù)語分詞效果有一些偏差——例如 “產(chǎn)品A系列” 可能被切為 “產(chǎn)品” + “A” + “系列”,“部件故障” 可能被切為 “部件” + “故障”。
目前通過在 query 改寫階段追加完整的專業(yè)詞匯作為緩解手段(即使分析器誤切,也能通過擴(kuò)展后的完整詞匹配到),后續(xù)考慮使用自定義詞典來進(jìn)一步優(yōu)化。
9. 總結(jié)與下一步
9.1 這套方案的核心價(jià)值
- 意圖優(yōu)先,RAG 按需觸發(fā):不是所有請(qǐng)求都需要全套檢索,兩階段 pipeline 在延遲和效果之間取得了好的平衡。
- 向量 + BM25 混合檢索:?jiǎn)我粩?shù)據(jù)庫(Milvus 2.5 內(nèi)置 BM25)搞定,不需要維護(hù)多個(gè)存儲(chǔ)引擎。
- 規(guī)則驅(qū)動(dòng)的 query 改寫:領(lǐng)域知識(shí)顯式編碼,可調(diào)試、可維護(hù),延遲為零。
- 多層過濾漏斗:從向量相似度閾值 → BM25 分過濾 → RRF 融合 → Rerank 雙重過濾,每層砍掉一部分噪聲,最終喂給 LLM 的是高度相關(guān)的上下文。
- 后處理門控:答案與用戶實(shí)際情況交叉校驗(yàn),降低風(fēng)險(xiǎn)。
9.2 當(dāng)前局限與改進(jìn)方向
- Query 改寫依賴規(guī)則維護(hù):新增產(chǎn)品線時(shí)需要手動(dòng)加規(guī)則。未來可以考慮用 LLM 生成候選擴(kuò)展詞 + 人工審核的半自動(dòng)化流程。
- BM25 分詞精度:通用 Chinese analyzer 對(duì)領(lǐng)域?qū)I(yè)術(shù)語的分詞有偏差,可以引入自定義詞典。
- Rerank 是唯一的外部依賴:如果 DashScope Rerank 服務(wù)不可用,系統(tǒng)雖有降級(jí)策略但排序質(zhì)量會(huì)下降??紤]評(píng)估本地 Cross-Encoder 模型作為備選。
- 評(píng)估體系缺失:目前靠人工抽樣評(píng)估檢索質(zhì)量。下一步需要構(gòu)建標(biāo)注數(shù)據(jù)集和自動(dòng)化評(píng)估
9.3 關(guān)鍵配置速查
| 配置項(xiàng) | 值 | 用途 |
|---|---|---|
hybrid.vector-pool-top-k | 40 | 向量召回候選數(shù) |
hybrid.keyword-pool-top-k | 24 | BM25 召回候選數(shù) |
hybrid.rrf-k | 60 | RRF 阻尼常數(shù) |
hybrid.rerank-candidate-max | 48 | Rerank 輸入上限 |
hybrid.min-vector-similarity | 0.15 | 向量相似度閾值 |
hybrid.min-bm25-absolute-score | 1.0 | BM25 絕對(duì)分閾值 |
hybrid.min-relative-retrieval-score | 0.8 | Rerank 相對(duì)分閾值 |
hybrid.min-rerank-absolute-score | 0.3 | Rerank 絕對(duì)分閾值 |
rag.ingest.max-chunk-chars | 1200 | 切片大小 |
rag.ingest.overlap-chars | 150 | 切片重疊量 |
以上就是基于Spring AI+Milvus的RAG混合檢索的實(shí)戰(zhàn)指南的詳細(xì)內(nèi)容,更多關(guān)于Spring AI Milvus RAG混合檢索的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springboot訪問請(qǐng)求404的原因及解決辦法
在使用Spring Boot開發(fā)應(yīng)用程序時(shí),有時(shí)可能會(huì)遇到訪問請(qǐng)求出現(xiàn)404錯(cuò)誤的情況,即請(qǐng)求的資源未找到,這篇文章主要給大家介紹了關(guān)于springboot訪問請(qǐng)求404的原因及解決辦法,需要的朋友可以參考下2023-09-09
springboot中如何通過main方法調(diào)用service或dao
這篇文章主要介紹了springboot中如何通過main方法調(diào)用service或dao,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-02-02
一文教你學(xué)會(huì)搭建SpringBoot分布式項(xiàng)目
這篇文章主要為大家詳細(xì)介紹了搭建SpringBoot分布式項(xiàng)目的相關(guān)知識(shí),文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2024-01-01
SpringBoot對(duì)數(shù)據(jù)庫用戶名和密碼進(jìn)行加密的兩種方案
在當(dāng)今互聯(lián)網(wǎng)時(shí)代,?數(shù)據(jù)庫安全的重要性不言而喻,作為Java開發(fā)的主流框架,Spring Boot項(xiàng)目通常需要連接數(shù)據(jù)庫,而數(shù)據(jù)庫的?用戶名和密碼作為最敏感的信息之一,所以本文給大家詳細(xì)介紹兩種主流的數(shù)據(jù)源配置加密方案,需要的朋友可以參考下2025-12-12
java 操作gis geometry類型數(shù)據(jù)方式
這篇文章主要介紹了java 操作gis geometry類型數(shù)據(jù)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-03-03
SpringMVC中的handlerMappings對(duì)象用法
這篇文章主要介紹了SpringMVC中的handlerMappings對(duì)象用法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09
Mybatis-plus默認(rèn)不能更新null字段的問題及解決過程
Mybatis-plus默認(rèn)忽略null值導(dǎo)致update失敗,解決方案包括單個(gè)配置(每個(gè)字段加注解)和全局配置(統(tǒng)一配置),全局配置簡(jiǎn)單高效,但需要靈活配置時(shí)可結(jié)合單個(gè)配置,注意配置的是update-strategy2025-11-11

