发布于2026-06-02 阅读(0)
扫一扫,手机访问
这次要解决的问题很明确:给一个垂直业务平台搭建智能客服助手。用户会直接描述问题,比如“产品A启动不了该怎么办”,或者“产品B的保养周期是多久”,系统需要给出准确、可操作的解决方案。

数据方面已经有了大量按产品线(A、B、C、D、E)分类的技术文档,主要是PDF和DOCX格式。现在要做的是搭一套检索增强生成(RAG)系统,把这些文档“喂”给大模型,让它能输出结构化的解决方案,而不是天马行空地乱说。
如果只是做一个“用户提问 → 向量检索 → LLM回答”的常规RAG,数据能洗干净吗?坦白讲,不够。
实际落地会碰到几个硬骨头:
| 组件 | 选型 | 理由 |
|---|---|---|
| 框架 | Spring Boot 3.4.5 + Spring AI 1.1.2 | Ja va生态成熟,Spring AI对向量存储和LLM调用有很好的封装 |
| 向量数据库 | Milvus 2.5.6 | 原生支持BM25内置函数,省去了额外维护一个ES |
| 嵌入模型 | DashScope text-embedding-v2 | 1536维,中文效果不错 |
| LLM | DashScope Qwen | 两阶段调用,意图分类和答案生成用同一模型,切换system prompt就行 |
| Rerank | DashScope Rerank | 跟LLM同一家供应商,延迟比较可控 |
整个问答系统的主流程分两大阶段:
┌─────────────────────────────────────────────────────────┐
│ 用户输入(问题文本) │
└─────────────────────┬───────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 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意图时,才会走完整的混合检索链路。这带来了几个好处:
核心服务类的职责分工:
| 层级 | 关键类 | 职责 |
|---|---|---|
| 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 函数字段的数据写入 |
要不要在入口处先做意图分类?这个问题的权衡其实很直接:
我们选了后者。Phase 1的意图分类prompt强制LLM返回严格的JSON格式:
你是产品技术支持领域的意图识别专家。根据用户问题,识别意图类型。
必须严格返回JSON格式:
{"intentType": "GENERAL_CONSULT"|"FIND_COMPONENT"|"VIEW_RECENT_FOLLOWUP_TASKS"}
这个阶段的推理负担极轻——不需要理解全文,不需要检索上下文,只需要判断用户到底想干什么。
| 意图 | 处理方式 | 延迟 |
|---|---|---|
GENERAL_CONSULT | LLM 直接回答,可选同步到 QA 库 | 低 |
VIEW_RECENT_FOLLOWUP_TASKS | 调用外部用户服务查历史数据 | 低 |
FIND_COMPONENT | 触发完整 RAG pipeline → LLM 生成解决方案 | 高 |
只有FIND_COMPONENT这一个意图会触发RAG。这里还有一道前置校验——通过外部服务确认用户确实关联了某种产品配置。如果用户没有相关产品却来问“产品A故障怎么办”,系统压根不会浪费时间去检索文档。
即使RAG召回了文档,LLM生成了回答,最后还有一关要过:
componentCode(产品类型编码)GENERAL_CONSULT,也就是只给一般性建议,绝不给具体操作指引这个门控很关键,因为在业务场景下,给错产品线的操作建议,比不给建议更危险。
混合检索是整个RAG系统的核心,我们来逐个步骤拆解。
纯向量检索(dense retrieval)的问题在于:
纯关键词检索(BM25 sparse retrieval)的问题在于:
两者结合,才能优势互补。
市面上的混合检索方案,通常需要两个引擎:Milvus/Qdrant做向量 + Elasticsearch做BM25。维护两套索引,还要在两个结果集之间做融合,架构复杂度不小。
Milvus 2.5开始支持内置BM25——通过FunctionType.BM25在schema中定义一个函数,将文本字段自动转换为稀疏向量。检索时用EmbeddedText将查询文本发给Milvus,服务端自动分词并计算BM25分数。
好处很明显:
当然也有代价:
核心入口在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,整个链路会退化为纯向量检索,方便做对照实验。
使用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条,也要多召回一些,给后续融合留出空间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能有效匹配的文档数本就有限。minBm25AbsoluteScore=1.0的阈值。BM25分数低于1.0的文档基本没有有意义的词法匹配,属于噪声。BM25_score / max(BM25_score)低于0.8的丢弃。这个阈值可以按需配置。Reciprocal Rank Fusion(RRF)是一种简洁有效的分数融合方法:
// 伪代码 Mapscores = 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. comparingByValue().reversed()) .limit(rerankCandidateMax) .toList();
RRF公式:RRF_score(d) = Σ 1 / (k + rank_i(d)),其中 k 是阻尼常数。
为什么用RRF,而不是简单的分数归一化?
传统分数融合需要做score normalization——比如min-max归一化。但问题是:
RRF不关心原始分数的绝对值,只关心相对排名,天然适合异构检索结果的融合。
k值的选择:我们设rrfK=60。k越大,排名差异的影响越小,更偏向于“同时出现在两个列表中的文档得分高”。k=60意味着:
1/(60+1) = 0.01641/(60+10) + 1/(60+5) = 0.0143 + 0.0154 = 0.02971/61 + 1/61 = 0.0328可以看到,出现在两个列表中的文档RRF分明显更高——这正是我们想要的:两个检索器“共识”的文档排前面。
前面提到,用户的自然语言和专业文档的书面表述之间存在gap。一个典型的例子:
| 用户问题 | 文档表述 |
|---|---|
| “设备发热” | “机身温度过高” |
| “接口松动” | “连接端口接触不良” |
| “多久换一次” | “配件更换周期/更换频率” |
如果不做改写,无论是向量检索还是BM25检索,都可能漏掉真正相关的文档。
RagQueryRewriteService.rewriteForRetrieval()执行三步处理:
Step 1: 文本归一化(Normalize)
String normalized = original.trim().replaceAll("\s+", " ");
最简单的清洗:去首尾空白、合并多余空格。
Step 2: 规则驱动的词扩展
维护了一个包含27条规则的映射表,每条规则定义了一组触发词和对应的扩展词:
rule(
new String[] {"保养周期", "保养频率", "多久保养", "检查周期",
"多长时间检测", "维护频率", "多久检查"},
new String[] {"常规保养间隔", "使用期间检查频率", "保养周期",
"安装后维护", "常规保养"}
)
匹配逻辑是:如果用户问题中间出现任意一个触发词,就将所有扩展词(未在问题原文中间出现的)追加到检索query后面。
为什么用规则而不是LLM做改写?
当一个领域的同义词是可枚举且有限的,规则引擎就是最好的选择。
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仍然使用用户原始的提问文本。
RRF融合的结果是基于两个检索器各自排名的折中,但它不知道哪个文档真正回答了用户的问题。
Rerank模型把每个候选文档和用户问题做一对一的语义相关性打分,比向量相似度的rank更精细。DashScope的Rerank模型(gte-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阶段希望获得更多的检索信号来排序。
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 才会被保留。
Rerank是可热切换的:
RerankModel rerank = hybridProperties.isUseRerankWhenA vailable()
? rerankModel.getIfA vailable() : null;
if (rerank == null) {
// 无 rerank,直接用 RRF 结果截断返回
return fused.stream().limit(topK).toList();
}
use-rerank-when-a vailable配置项设为false即可跳过rerank,这对于压测和故障降级很有用。
降级策略:如果rerank调用返回空结果,系统回退到RRF融合列表截断topK输出。但如果rerank返回了结果但全被过滤掉了(双重过滤导致),则不降级——返回空结果,不兜底。因为“找到但全不相关”比“假装找到了”更诚实,也避免LLM拿到无关上下文后胡编乱造。
文档上传通过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);
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);
}
}
适合有明确段落结构的产品技术文档。保留了文档的自然段落边界,检索时上下文的连贯性更好。
这是整个项目中最“工程化”的一个坑。
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的自动嵌入流程。
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索引参数:
MapsparseParams = 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秒),避免索引未就绪时查询返回空结果。
最开始设向量池100、BM25池100,觉得“多召回一些,反正后续有rerank筛选”。结果:
现在稳定在向量池40、BM25池24、rerank候选上限48。实测延迟降到1.2秒以内(全链路)。
原则:多路检索的池大小是“够用就好”,不要贪多。
一开始只设置了相对分过滤(minRelativeRetrievalScore=0.8),没有绝对分过滤。发现在某些边缘query上——用户问“今天天气怎么样”——Rerank返回的所有文档分数都很低(~0.1),归一化后最高分也只有0.1,相对分过滤形同虚设(0.1/0.1=1.0,全通过)。
加上minRerankAbsoluteScore=0.3后,这种情况会被正确拦截——所有rerank分低于0.3的文档直接丢弃,返回空列表,LLM至少知道“没有找到相关文档”而不是对着无关内容瞎编。
Spring AI 1.1.2的Milvus集成基于v1 gRPC客户端,而Milvus 2.5的部分功能(EmbeddedText、BM25函数)只在v2 gRPC中可用。这导致了两个API路径共存:
教训:框架的封装层不一定总是够用。对于前沿功能(如Milvus 2.5内置BM25),要做好绕过框架直接调底层SDK的准备。 我们做了封装隔离——只在底层交互类中依赖v2 SDK,上层业务逻辑仍然通过统一接口调用。
最开始给每条规则加了很多扩展词,比如“故障”触发词映射到十几个扩展词。结果某些query改写得非常长(几百字),反而稀释了核心关键词的信号。
现在的策略是每条规则3-5个精准的扩展词。宁可少写,不要多写。
k=60不是拍脑袋定的。在开发环境做了小规模对比:
| k 值 | 效果 |
|---|---|
| k=0 | 极端重视高 rank,排第一的文档权重极大。结果:几乎退化为“取两个列表的交集排序”,单路表现差的 query 全挂 |
| k=60 | 当前的平衡点,共识文档有优势,但单路高排名的文档也不会被完全埋没 |
| k=∞ | 所有 rank 权重相同,退化为“在两个列表中都出现的排前面,都没出现的随机排” |
k=60在开发和测试阶段的表现稳定,所以沿用到了生产。
Milvus的Chinese analyzer对中文文本的处理包括分词和停用词过滤。我们发现通用Chinese analyzer对垂直领域的专业术语分词效果有一些偏差——例如“产品A系列”可能被切为“产品” + “A” + “系列”,“部件故障”可能被切为“部件” + “故障”。
目前通过在query改写阶段追加完整的专业词汇作为缓解手段(即使分析器误切,也能通过扩展后的完整词匹配到),后续考虑使用自定义词典来进一步优化。
| 配置项 | 值 | 用途 |
|---|---|---|
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 | 切片重叠量 |
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8