商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > 基于SpringAI+Milvus的RAG混合检索的实战指南

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

  发布于2026-06-02 阅读(0)

扫一扫,手机访问

1. 背景与动机

1.1 业务场景

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

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

数据方面已经有了大量按产品线(A、B、C、D、E)分类的技术文档,主要是PDF和DOCX格式。现在要做的是搭一套检索增强生成(RAG)系统,把这些文档“喂”给大模型,让它能输出结构化的解决方案,而不是天马行空地乱说。

1.2 核心挑战

如果只是做一个“用户提问 → 向量检索 → LLM回答”的常规RAG,数据能洗干净吗?坦白讲,不够。

实际落地会碰到几个硬骨头:

  1. 意图太杂:用户不是说每句话都需要翻文档的。像“你好”这种打招呼,或者“我上次的工单怎么样了”这种查记录,直接走LLM或者查数据库就行。每次无差别触发全套检索,完全是浪费资源。
  2. 精准召回是个老大难:纯向量检索对专业术语的理解能力有限。Most of the time,用户说“产品C运行异常”和文档里的“产品C系统故障”,在语义空间里可能很近,但纯向量一搜,关键信息可能就漏掉了。
  3. 检索噪声大:一次召回40条候选,里面可能超过一半是与问题不相关的。把这些噪音一股脑塞给大模型,不但费钱(token消耗),还容易把大模型带偏。
  4. 用户和文档说话的方式不一样:用户嘴里说“设备发热”,但写文档的人用的是“机身温度过高”;用户说“接口松动”,文档写的是“连接端口接触不良”。不做点query扩展,根本检索不到目标内容。

1.3 选型

组件选型理由
框架Spring Boot 3.4.5 + Spring AI 1.1.2Ja va生态成熟,Spring AI对向量存储和LLM调用有很好的封装
向量数据库Milvus 2.5.6原生支持BM25内置函数,省去了额外维护一个ES
嵌入模型DashScope text-embedding-v21536维,中文效果不错
LLMDashScope Qwen两阶段调用,意图分类和答案生成用同一模型,切换system prompt就行
RerankDashScope Rerank跟LLM同一家供应商,延迟比较可控

2. 整体架构概览

整个问答系统的主流程分两大阶段

┌─────────────────────────────────────────────────────────┐
│                    用户输入(问题文本)                    │
└─────────────────────┬───────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────┐
│              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)
  • 两个阶段的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"}

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

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 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.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.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拿到无关上下文后胡编乱造。

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 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切片重叠量
本文转载于:https://www.jb51.net/program/364990zo3.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注