第9小节:向量数据库的原理与选型
mysql你写成json文件,检索的时候,你必须把所有向量都读出来,在内存里逐个计算相似度。这就是所谓的暴力搜索,
- 1.把用户的问题也向量化,得到一个 4096 维的查询向量
- 2.从数据库里读出 100 万个向量
- 3.逐个计算查询向量和这 100 万个向量的余弦相似度
- 4.排序,取相似度最高的 Top-K 个
- 2s-5s只是单次查询的耗时,如果有 100 个用户同时提问呢?
- 数据量还会增长,500 万、1000 万个 chunk 呢?
- 实际的 RAG 系统对延迟很敏感,用户问一个问题不只是向量检索,还有很多其他步骤,体验很差
- 而且每次查询都要把 100 万个向量从磁盘读到内存,I/O 开销也很大
近似最近邻搜索
既然逐个比较太慢,能不能不比较所有向量,只比较其中一部分,就找到大概率最相似的那几个?ANN 则是先按区域划分,再按特征缩小范围,最后只在一小撮人里精确比较。
答案是可以。这就是 ANN(Approximate Nearest Neighbor,近似最近邻搜索)的核心思想。
注意这里的关键词是“近似”——ANN 不保证找到的一定是全局最相似的向量,但它能在极短的时间内找到非常接近最优解的结果。
讲两种最主流的 ANN 索引算法:IVF 和 HNSW
2. IVF(倒排文件索引):先分区再搜索
IVF 的全称是 Inverted File Index(倒排文件索引)。名字听起来很学术,但思路非常直觉。
2.1 IVF 的工作原理
IVF 的核心思想就一句话:把向量空间划分成若干个区域,查询时只在最可能的几个区域里搜索。
具体怎么做?分两个阶段:
建索引阶段(离线):
- 1.用聚类算法(通常是 K-Means)把所有向量分成
nlist个簇(cluster) - 2.每个簇有一个中心点(centroid),代表这个簇里所有向量的"平均位置"
- 3.每个向量被分配到离它最近的那个簇
检索阶段(在线):
- 1.拿到查询向量后,先计算它和所有簇中心点的距离
- 2.找到最近的
nprobe个簇(nprobe 是一个可调参数) - 3.只在这
nprobe个簇里的向量中做精确搜索
假设 nlist = 100(分成 100 个簇),nprobe = 10(查询时搜索 10 个簇),那么每次查询只需要搜索大约 10% 的向量,速度提升约 10 倍。
IVF 的优缺点
| 优点 | 缺点 |
|---|---|
| 原理简单,容易理解和调优 | 需要训练聚类模型(数据量大时训练较慢) |
| 内存占用相对较低 | 聚类边界处的向量可能被漏掉(影响召回率) |
| 适合数据量非常大的场景 | nlist 和 nprobe 的调参需要经验 |
| 支持增量插入(但可能需要定期重新聚类) | 数据分布不均匀时效果下降 |
IVF 还有几个变体:IVF_FLAT 是在簇内做精确搜索,IVF_SQ8 是对簇内向量做量化压缩以节省内存,IVF_PQ 则用乘积量化进一步压缩。后面的选型表格里会对比它们的差异。
HNSW(分层可导航小世界图):最主流的索引算法
HNSW 的全称是 Hierarchical Navigable Small World Graph(分层可导航小世界图)。名字很长,但它是目前最主流、效果最好的 ANN 索引算法,几乎所有向量数据库都把它作为默认或推荐的索引类型。
HNSW 的核心结构是一个多层图:
- 最底层(Layer 0)包含所有向量,每个向量和它附近的若干个向量相连
- 往上每一层的向量数量越来越少(随机抽取),但连接的跨度越来越大
- 最顶层只有很少的几个向量,但它们之间的连接覆盖了整个向量空间
检索的时候,从最顶层开始,快速定位到目标的大致区域,然后逐层下降,每一层都在更精细的范围内搜索,最终在最底层找到最相似的向量。
为了让你更直观地理解,咱们用一个简化的例子走一遍。
假设向量数据库里有 8 个向量(A、B、C、D、E、F、G、H),HNSW 建了 3 层图。现在要查询和向量 Q 最相似的向量。
Layer 2(顶层):只有 A 和 E 两个向量
- 从 A 开始,计算 Q 和 A 的距离、Q 和 E 的距离
- 发现 E 离 Q 更近,移动到 E
Layer 1(中间层):有 A、C、E、G 四个向量
- 从 E 出发,看 E 的邻居:C 和 G
- 计算 Q 和 C、Q 和 G 的距离
- 发现 G 离 Q 更近,移动到 G
Layer 0(底层):所有 8 个向量都在
- 从 G 出发,看 G 的邻居:F 和 H
- 计算 Q 和 F、Q 和 H 的距离
- 发现 H 离 Q 最近
- 再看 H 的邻居,没有比 H 更近的了
- 结果:H 是和 Q 最相似的向量
整个过程只计算了 6 次距离(A、E、C、G、F、H),而不是 8 次。数据量小的时候差距不明显,但如果有 100 万个向量,HNSW 通常只需要计算几百到几千次距离就能找到结果。
HNSW 快的原因可以归结为两点:
第一,多层结构实现了“粗到细”的搜索。顶层的少量向量帮你快速跳到目标附近,底层的密集连接帮你精确定位。这和跳表(Skip List)的思想很像——如果你了解 Redis 的有序集合(ZSet),它底层用的就是跳表,原理是相通的。
第二,“小世界”特性保证了图的连通性。在 HNSW 的图中,任意两个向量之间只需要经过很少的“跳转”就能到达(类似“六度分隔理论”——你和世界上任何一个人之间最多只隔 6 个人)。这意味着搜索不会陷入死角,总能快速逼近目标。
HNSW 的检索速度和精度都很优秀,但它有一个明显的代价:内存占用大。
因为 HNSW 需要在内存中维护整个图结构——不仅要存所有向量本身,还要存向量之间的连接关系(边)。每个向量在每一层都有若干条边,这些边的存储开销不小。
具体来说,HNSW 有两个关键参数影响内存和性能:
| 参数 | 含义 | 调大的效果 | 调小的效果 |
|---|---|---|---|
| M | 每个向量在每层的最大连接数 | 召回率更高,但内存占用更大,建索引更慢 | 内存省,但召回率可能下降 |
| efConstruction | 建索引时的搜索宽度 | 索引质量更高(连接更合理),但建索引更慢 | 建索引快,但索引质量可能下降 |
检索时还有一个参数 ef(搜索宽度),控制检索时探索的候选集大小。ef 越大,召回率越高,但检索越慢。
一个粗略的估算:100 万个 4096 维向量,用 HNSW 索引(M=16),大约需要 16~20 GB 内存。如果你的服务器内存有限,可能需要考虑 IVF 系列索引,它们的内存占用要小得多。
Milvus 支持多种索引类型,下面是最常用的几种对比:
| 索引类型 | 核心思想 | 检索速度 | 召回率 | 内存占用 | 适用数据量 | 适用场景 |
|---|---|---|---|---|---|---|
| FLAT | 暴力搜索,不建索引 | 最慢 | 100%(精确) | 低(只存原始向量) | < 10 万 | 对精度要求极高,数据量小 |
| IVF_FLAT | 聚类分区 + 簇内精确搜索 | 快 | 95%~99% | 较低 | 百万~千万 | 数据量大,内存有限 |
| IVF_SQ8 | 聚类分区 + 标量量化压缩 | 快 | 93%~97% | 低(向量压缩为 1/4) | 千万~亿级 | 数据量很大,愿意牺牲一点精度换内存 |
| HNSW | 多层图结构 | 最快 | 97%~99.5% | 高(需存图结构) | 百万~千万 | 对速度和精度都有要求,内存充足 |
| DISKANN | 基于磁盘的图索引 | 较快 | 95%~98% | 低(索引在磁盘) | 亿级 | 数据量极大,内存不够放 HNSW |
对于大多数 RAG 项目来说,数据量在百万级别,HNSW 是最优选择。这也是为什么后面的实战代码里我们用 HNSW 作为索引类型。
1. 向量数据库的分类
1.1 专用向量数据库
从零开始为向量检索设计的数据库,向量是一等公民。代表产品:Milvus、Qdrant、Weaviate、Pinecone、Chroma。
它们的特点是:原生支持多种 ANN 索引算法,针对向量检索做了大量底层优化(内存管理、并行计算、索引构建等),通常还支持标量过滤(在向量检索的同时按元数据条件过滤)。
1.2 传统数据库的向量扩展
在已有的关系型数据库上加一个向量检索插件。代表产品:pgvector(PostgreSQL 的扩展)、MySQL 8.0+ 的向量支持、Elasticsearch 的 kNN 搜索。
它们的优势是不用引入新的基础设施——如果你的项目已经在用 PostgreSQL,加一个 pgvector 扩展就能存向量、做检索,运维成本低。但在大数据量下的检索性能、索引类型的丰富度、以及向量检索的专项优化上,和专用向量数据库还是有差距。
我们选择最经典的Milvus,其很好,faiss和一系列衍生项目还用的他呢
1. Collection = 表
Collection 是 Milvus 中数据组织的基本单位,对应 MySQL 中的表(Table)
2. Schema = 表结构
Schema 定义了 Collection 中每条数据包含哪些字段,对应 MySQL 中的表结构(CREATE TABLE 时定义的列)。
一个典型的 RAG 场景的 Schema 包含三类字段:
| 字段类型 | 示例 | 说明 |
|---|---|---|
| 主键字段 | id(Int64 或 VarChar) | 每条数据的唯一标识,类似 MySQL 的主键 |
| 向量字段 | vector(FloatVector) | 存储 Embedding 向量,需要指定维度 |
| 标量字段 | chunk_text、doc_id、category 等 | 存储元数据,用于过滤和展示 |
2.1 向量字段和标量字段的区别
这里要特别说明一下向量字段和标量字段的区别,因为这是 Milvus 和传统数据库最大的不同。
标量字段存储的是普通数据(字符串、数字、布尔值等),和 MySQL 的列没什么区别。你可以对标量字段建索引、做等值查询、范围查询、模糊匹配等。
向量字段存储的是高维浮点数数组(比如 4096 维的 float 数组),它不能做等值查询(两个向量完全相等的概率几乎为零),只能做相似度检索(找最近的 Top-K 个)。向量字段需要建专门的向量索引(HNSW、IVF 等),这和标量字段的 B+ 树索引是完全不同的东西。
3. Index = 索引
Milvus 中的索引分两种:
- 向量索引:为向量字段创建的 ANN 索引(HNSW、IVF_FLAT 等),用于加速向量相似度检索。这是 Milvus 的核心能力。
- 标量索引:为标量字段创建的索引,用于加速过滤条件的执行。类似 MySQL 的 B+ 树索引。
在 RAG 场景中,通常需要同时用到两种索引:向量索引用于找到语义最相似的 chunk,标量索引用于按元数据过滤(比如只搜索某个类别的 chunk)。
4. Partition = 分区
Partition 是 Collection 内部的数据分区,对应 MySQL 的分区表。
检索的时候可以指定只在某个 Partition 里搜索,这样搜索范围更小,速度更快。
不过需要注意:Partition 不是必须的。如果你的数据量不大(< 100 万),或者没有明确的分区维度,不分区也完全没问题。用标量过滤(在 WHERE 条件里加 category = 'return_policy')也能达到类似的效果,只是在数据量很大时性能不如 Partition。
| Milvus 概念 | MySQL 对应 | 说明 |
|---|---|---|
| Collection | Table | 数据的基本组织单位 |
| Schema | CREATE TABLE 的列定义 | 定义字段名、类型、约束 |
| Field | Column | 单个字段 |
| Partition | 分区表的 Partition | 按业务维度划分数据 |
| 向量索引(HNSW 等) | 无直接对应 | MySQL 没有向量索引 |
| 标量索引 | B+ 树索引 | 加速标量字段的查询 |
| Entity | Row | 一条数据记录 |

用 cat > docker-compose.yml << 'EOF' 方式写入,避免直接粘贴带隐藏字符的内容。用 heredoc 方式写入(避免粘贴污染)
启动后,访问 http://localhost:8000 可以打开 Attu 管理界面,直观地查看 Collection、数据和索引。
如果你机器上部署了minio等东西占用的9000端口,记得改一下
ports:
- "9002:9000" # ← 主机 9002 映射到容器 9000
- "9003:9001" # ← 主机 9003 映射到容器 9001

首先是项目依赖,除了 Milvus 的 SDK(选 2.6.6 版本是因为它支持更简洁的 v2 API),还需要 OkHttp 来调用 SiliconFlow 的 Embedding 接口,以及 Gson 处理 JSON 数据。这部分配置比较常规,放在 pom.xml 里即可。
连接上 Milvus 之后,核心是设计 Collection 的 Schema。这里有个硬性约束:向量字段的 dimension 必须跟 Embedding 模型输出一致。我用的是 Qwen3-Embedding-8B,输出 4096 维,所以 schema 里 vector 字段必须填 4096,否则后续插入会报错。字段设计上,除了主键 id(开了 autoID 自动生成)和向量 vector,还加了 chunk_text 存原文,doc_id 追踪文档来源,category 用来做分类过滤。VarChar 类型记得指定 maxLength,比如 chunk_text 给 8192 足够存一个片段。
数据插入阶段,实际流程是“文档→提取→分块→向量化→入库”。Demo 里为了测试方便,直接模拟了几条电商数据并调用 API 生成真实向量。SDK v2 插入数据是用 List<JsonObject> 格式,每个对象代表一行。这里有个容易困惑的地方:插入成功后去 Attu 管理界面看,可能显示 0 条数据。这不是没插进去,而是因为还没建索引和加载 Collection 到内存,Milvus 默认不加载是不可查也不可见的。
说到索引,数据插完后必须建索引才能检索,不然就是暴力搜索。向量字段我选了 HNSW 索引,度量方式用 COSINE(余弦相似度),这两个参数要跟 Embedding 模型的特性匹配。HNSW 的 M 设为 16,efConstruction 设为 256,这是在召回率和建索引速度之间做个平衡。标量字段 category 加了 TRIE 索引,方便后面做字符串过滤。建完索引后,务必调用 loadCollection 把数据加载到内存,这是 Milvus 的机制,检索是在内存里进行的。
检索部分,先把用户问题向量化,然后构造 SearchReq。几个关键参数要注意:topK 决定返回几条结果,RAG 场景一般取 3~10 条;searchParams 里的 ef 控制搜索宽度,一般是 topK 的 4~16 倍,越大越准但越慢。返回结果里可以拿到相似度分数和之前定义的标量字段(如 chunk_text、category)。
实际使用中,纯向量检索有时候不够准,比如问“退货”可能会搜出“物流”。这时候可以用 混合检索,在 search 请求里加 filter 参数。语法类似 SQL 的 Where 子句,比如 category == "return_policy" 就能强制只在退货政策里搜。这个功能在多租户隔离、权限控制或者时效性过滤场景下非常有用。
最后总结一下整个流程的逻辑:创建 Collection、插入数据、建索引属于离线准备阶段,这些数据准备好之后可以持久化;加载 Collection、执行检索属于在线查询阶段,每次服务启动或数据变更后可能需要重新加载。把这两阶段分开理解,后续做系统架构会清晰很多。
实际项目中的关键决策
跑通了 demo,接下来聊聊实际项目中你会遇到的几个关键决策。这些决策没有标准答案,取决于你的数据量、性能要求和资源限制。
1. 索引类型怎么选
前面的“索引算法对比”表格已经给了一个大致的方向,这里再给一个更实操的决策流程:
- 先问自己:数据量有多大?
- < 10 万条:直接用
FLAT,暴力搜索就够了,省去调参的麻烦 - 10 万 ~ 500 万条:优先选
HNSW,速度快、召回率高 - 500 万 ~ 5000 万条:看内存够不够。够就
HNSW,不够就IVF_SQ8(向量压缩到原来的 1/4) - 5000 万条:考虑
DISKANN(索引放磁盘)或IVF_PQ(更激进的压缩)
- < 10 万条:直接用
- 再问自己:对召回率的要求有多高?
- RAG 场景通常取 Top-5 到 Top-10,对召回率的容忍度较高,
HNSW和IVF_FLAT都能满足 - 如果是人脸识别、指纹匹配等对精度要求极高的场景,可能需要
FLAT或者把 HNSW 的参数调得很大
- RAG 场景通常取 Top-5 到 Top-10,对召回率的容忍度较高,
对于大多数 RAG 项目,HNSW 是默认选择,不需要纠结。
2. 相似度度量怎么选
Milvus 支持三种相似度度量方式:
| 度量方式 | 公式直觉 | 值域 | 越大越相似? | 适用场景 |
|---|---|---|---|---|
| COSINE(余弦相似度) | 衡量两个向量方向的夹角 | [-1, 1] | 是 | 文本语义检索(最常用) |
| IP(内积) | 衡量两个向量的方向和大小 | (-∞, +∞) | 是 | 向量已归一化时等价于余弦 |
| L2(欧氏距离) | 衡量两个向量在空间中的直线距离 | [0, +∞) | 否(越小越相似) | 图像检索、推荐系统 |
怎么选?一个简单的原则:看你用的 Embedding 模型推荐哪种。
- 大多数文本 Embedding 模型(包括 Qwen3-Embedding-8B、OpenAI text-embedding-3 等)输出的向量已经做了归一化处理,用
COSINE或IP效果一样 - 如果不确定模型是否做了归一化,用
COSINE最安全——它会自动处理向量长度的差异 L2在文本检索场景中用得较少,更多用在图像、音频等领域
本系列统一使用 COSINE。
3. 分区策略设计
Milvus 的 Partition 可以按业务维度把数据分开存储,检索时指定 Partition 可以缩小搜索范围。
常见的分区策略:
| 分区维度 | 示例 | 适用场景 |
|---|---|---|
| 按文档类别 | return_policy、logistics、promotion | 知识库有明确的分类体系 |
| 按租户 | tenant_001、tenant_002 | 多租户 SaaS 系统 |
| 按时间 | 2026_Q1、2026_Q2 | 数据有明显的时效性 |
不过,分区不是必须的。在以下情况下,用标量过滤(filter)替代分区更简单:
- 分类维度的值很多(比如上百个类别),创建太多 Partition 会增加管理复杂度
- 查询时经常需要跨多个分类检索
- 数据量不大(< 100 万),标量过滤的性能开销可以忽略
一个经验法则:如果某个过滤条件在 90% 以上的查询中都会用到,且值的种类不超过几十个,可以考虑用 Partition;否则用标量过滤就够了。
4. 数据更新策略
知识库不是一成不变的——文档会更新、会删除、会新增。向量数据库里的数据需要和源文档保持同步。
Milvus 目前不支持原地更新(update)单条数据的向量字段,所以更新的标准做法是删旧插新:
importio.milvus.v2.service.vector.request.DeleteReq;// 1. 删除旧数据(通过 doc_id 定位)DeleteReq deleteReq =DeleteReq.builder().collectionName("customer_service_chunks").filter("doc_id == \"doc_return_001\"").build();
client.delete(deleteReq);// 2. 对更新后的文档重新分块、向量化// 3. 插入新数据(同前面的插入流程)
这里 doc_id 的作用就体现出来了——它是连接源文档和向量数据库的纽带。当一个文档更新时,通过 doc_id 找到这个文档对应的所有 chunk,全部删除,然后重新分块、向量化、插入。
实际项目中的更新策略通常是这样的:
- 1.源文档变更时,触发一个异步任务
- 2.任务根据
doc_id删除 Milvus 中该文档的所有旧 chunk - 3.对新文档重新执行 Tika 提取 → 分块 → 向量化 → 插入 Milvus 的完整流程
- 4.整个过程对用户无感知,检索服务不中断
5. 性能调优的几个关键参数
最后汇总一下影响性能的关键参数,方便你在实际项目中调优:
5.1 HNSW 索引参数
| 参数 | 作用 | 推荐值 | 调大 | 调小 |
|---|---|---|---|---|
| M | 每个向量的最大连接数 | 8~32,通常 16 | 召回率↑,内存↑,建索引速度↓ | 内存↓,召回率可能↓ |
| efConstruction | 建索引时的搜索宽度 | 128~512,通常 256 | 索引质量↑,建索引速度↓ | 建索引速度↑,索引质量↓ |
| ef | 检索时的搜索宽度 | topK 的 4~16 倍 | 召回率↑,检索速度↓ | 检索速度↑,召回率↓ |
一个实用的调参思路:先用默认值(M=16,efConstruction=256,ef=topK×8)跑起来,然后根据实际的召回率和延迟表现微调。大多数情况下默认值就够用了。
5.2 IVF 索引参数
| 参数 | 作用 | 推荐值 | 调大 | 调小 |
|---|---|---|---|---|
| nlist | 聚类的簇数量 | 数据量的平方根(如 100 万数据用 1024) | 每个簇更小,检索更快,但训练更慢 | 每个簇更大,检索更慢 |
| nprobe | 检索时搜索的簇数量 | nlist 的 5%~10% | 召回率↑,检索速度↓ | 检索速度↑,召回率↓ |
5.3 通用建议
- 向量维度越高,检索越慢、内存占用越大。如果你的 Embedding 模型支持多种维度输出(比如 Qwen3-Embedding-8B 支持 512~4096),在精度够用的前提下可以选较低的维度
- 批量插入比逐条插入快得多,建议每批 1000~5000 条
- 如果数据量很大但查询 QPS 不高,可以考虑用
DISKANN把索引放磁盘,节省内存 - 定期监控检索延迟和召回率,Milvus 的 Attu 管理界面可以看到基本的性能指标
第10小节:向量检索策略与召回优化
假设你在维护一个电商客服知识库,用户问:订单号 2026012345 的物流状态是什么?你把这句话向量化后去 Milvus 里检索,返回的 Top-5 结果可能是:
- 物流配送时效说明
- 如何查询订单物流
- 物流异常处理流程
- 快递公司合作列表
- 订单状态说明
这就是纯向量检索的短板:它擅长理解语义,但对精确关键词不敏感。
1. 场景一:精确关键词丢失
再看一个例子。用户问:iPhone 16 Pro Max 的退货政策是什么?
向量检索会把这句话理解成"退货政策"的语义,返回所有和退货相关的 chunk:
- 七天无理由退货说明
- 退货流程和注意事项
- 不支持退货的商品类型
- 退货运费承担规则
- 退货审核时效
这些 chunk 确实都和退货有关,但用户要的是 iPhone 16 Pro Max 这个具体型号的退货政策。如果知识库里有一条专门针对这个型号的规则(比如 iPhone 16 Pro Max 因屏幕定制不支持七天无理由退货),向量检索不一定能把它排到前面,因为它更关注退货这个语义,而不是 iPhone 16 Pro Max 这个精确关键词。
2. 场景二:专有名词和缩写
用户问:RMA 流程是什么?
RMA(Return Merchandise Authorization,退货授权)是一个专有缩写。向量检索可能把它理解成退货流程的语义,返回一堆通用的退货说明,但如果知识库里有一条专门讲 RMA 流程的文档,关键词匹配能直接命中,而向量检索可能把它排到很后面。
3. 场景三:数字和编号
用户问:2026 年春节发货安排是什么?
向量检索会把 2026 年春节理解成春节发货的语义,可能返回历年的春节发货安排(2024 年、2025 年、2026 年都有),但不一定优先返回 2026 年的那条。关键词检索能精确匹配 2026 这个数字,直接命中最相关的结果。
4. 向量检索和关键词检索的互补关系
| 维度 | 向量检索 | 关键词检索 |
|---|---|---|
| 擅长场景 | 语义理解、同义词、跨语言、意图匹配 | 精确关键词、专有名词、数字编号、缩写 |
| 典型 query | 买了一周的东西还能退吗(同义词) | 订单号 2026012345(精确匹配) |
| 短板 | 对精确关键词不敏感,容易丢失具体信息 | 无法理解语义,同义词匹配不上 |
| 底层原理 | 把文本转成向量,计算语义相似度 | 统计词频和文档频率,计算关键词重要性 |
它们不是谁替代谁的关系,而是互补的。理想的检索策略是把两者结合起来——用向量检索理解语义,用关键词检索补充精确匹配,取长补短。
BM25(Best Matching 25)是一个经典的关键词检索算法
BM25 不需要理解语义,它只看三个核心因素:
2.1 词频(TF):出现越多越相关,但有上限
如果一个词在文档里出现得越多,说明这个文档和这个词越相关。比如用户搜退货,一个文档里退货出现了 10 次,另一个文档里只出现了 1 次,前者大概率更相关。
但这里有个问题:如果一个文档里退货出现了 100 次,是不是就比出现 10 次的文档相关 10 倍?不一定。可能只是这个文档比较啰嗦,或者是机器生成的垃圾内容。所以 BM25 对词频做了饱和处理——出现次数从 1 增加到 10,分数涨得快;从 10 增加到 100,分数涨得慢;再往上基本不涨了。
用一句话概括:出现越多越相关,但有上限,避免长文档刷词占便宜。
2.2 逆文档频率(IDF):越稀有的词越有区分度
如果一个词在所有文档里都很常见(比如:的、是、有),那它没什么区分度,出现了也不能说明文档和查询相关。反过来,如果一个词很稀有(比如:iPhone 16 Pro Max、RMA、订单号 2026012345),那它出现在哪个文档里,哪个文档就很可能是用户要找的。
BM25 用逆文档频率(Inverse Document Frequency,IDF)来衡量一个词的稀有程度。计算方式是:看这个词在多少个文档里出现过,出现得越少,IDF 越高,权重越大。
用一句话概括:越稀有的词越有区分度,越能帮你找到目标文档。
2.3 文档长度归一化:长文档不能占便宜
如果一个文档很长(比如 5000 字),另一个文档很短(比如 500 字),长文档里出现查询词的概率天然更高。但这不代表长文档就更相关——可能只是因为它废话多。
BM25 会对文档长度做归一化:如果一个文档比平均长度长,它的分数会被打折;如果比平均长度短,分数会被提升。这样长文档和短文档就站在同一起跑线上了。
用一句话概括:长文档不能因为块头大就占便宜,要按长度归一化。
BM25 擅长精确匹配,向量检索擅长语义理解。所以实际 RAG 系统中,通常会把两者结合起来,这就是“混合检索”(Hybrid Search)。
Milvus 从 2.5 版本开始内置了全文检索能力,支持 BM25 算法。你可以在创建 Collection 时指定一个 VarChar 字段用于全文检索,Milvus 会自动对这个字段做分词和倒排索引,支持 BM25 检索。
如果你用的 Milvus 版本低于 2.5,或者对中文分词有更高要求,也可以用外部方案——比如用 Elasticsearch 做关键词检索,Milvus 做向量检索,应用层把两路结果融合起来。这种方案架构复杂一些,但全文检索能力更强。我们使用Milvus的原生方案,降低环境搭建成本。
ES 负责关键词检索,Milvus 负责向量检索,应用层做结果融合。
优点:
- ES 的全文检索能力成熟强大
- 中文分词生态好(IK 分词器、HanLP 等)
- 查询语法丰富,支持复杂的布尔查询、短语查询、同义词扩展等
- 适合已有 ES 基础设施的团队
缺点:
- 需要维护两套系统,运维复杂度高
- 数据双写带来一致性问题(写入 Milvus 成功但写入 ES 失败怎么办?)
- 大多数 RAG 场景用 Milvus 原生方案就够了,架构简单,成本低
- 如果对中文分词有高要求(比如需要自定义词典、同义词扩展),再考虑 ES + Milvus
- 如果已有 ES 基础设施,且团队对 ES 很熟悉,可以考虑双系统方案
- 如果需要复杂的全文检索语法(布尔查询、短语查询、聚合等),ES + Milvus 更合适
混合检索就是把文科生和理科生组合起来,既保证能理解模糊的意图,又保证不会漏掉精准的关键词。而并行处理则是为了省时间:让这两个人同时去库里找资料,而不是一个找完另一个再找。
二、 按时间轴的步骤拆解
1. 发起请求 (Query)
- 最左侧的用户输入了一个问题:Query: "退货流程"。这个请求同时发送给了后端的两个检索模块。
2. 并行检索阶段 (图中粗黑框 par 部分)
par 代表 Parallel(并行)。系统分兵两路,同时进行:
- 上路:向量检索 (语义维度)
- 查询进入: 收到“退货流程”。
- Embedding 编码: 这是AI的核心操作。把“退货流程”这四个字,通过大模型转化成一串长长的数字(向量),这串数字代表了这句话的“语义特征”。
- ANN 搜索 (去 Milvus 数据库): ANN指“近似最近邻”。带着这串数字去向量数据库(Milvus)里比对,找出和它距离最近、长得最像的其他向量数据。
- 返回结果: 拿到**“语义相似结果”**(比如包含“退换货”、“售后服务”的文档片段)。
- 下路:BM25检索 (词汇维度)
- 查询进入: 收到“退货流程”。
- 分词 + TF-IDF: 把句子切开,比如切成“退货”和“流程”。然后评估哪个词更重要(TF-IDF算法)。
- 全文检索 (去 Milvus/底层数据库): 拿着这两个词,去数据库的“倒排索引”里找,哪些文章里精确包含了这两个词。
- 返回结果: 拿到**“关键词匹配结果”**。
3. 结果合并与重排阶段 (RRF 融合)
- 交卷: 上路的向量检索交出了 “结果集 A”,下路的BM25交出了 “结果集 B”。这两份答卷都提交给了最右侧的 RRF融合 模块。
- RRF 排名融合 (核心算法): RRF 全称是 Reciprocal Rank Fusion(倒数排名融合)。
- 为什么要融合?因为A集合是按“语义相似度分数”排的,B集合是按“关键词匹配分数”排的,两套分数体系不一样,没法直接比大小。
- RRF 怎么做?它不看绝对分数,只看名次。如果一篇文章在A集合排第1,在B集合排第2,综合起来它肯定是个好结果;如果文章只在A排第1,在B里压根没出现,那它的综合排名就会往后靠。
- 一句话解释RRF: 综合考量两个排行榜,在两个榜单里排名都靠前的,最终排名就高。
4. 返回最终结果
- 融合模块计算完毕后,截取排名最靠前的 K 个结果(Top-K 综合结果),沿着最下方蓝色的虚线,直接返回给用户。
三、 总结:这个流程高级在哪里?
- 召回率极高(找得全): 同义词和错别字交给向量,专有名词和编号交给BM25,双管齐下,几乎不会漏掉任何相关的参考资料。
- 性能极佳(速度快): 利用 par 并发机制,总耗时只取决于两路检索中较慢的那一个,而不是两者的耗时相加。
- 排序科学(排得准): 没有生硬地拼接结果,而是使用业界公认有效的 RRF 算法进行名次重排,确保呈现给用户的 Top-K 是质量最高的参考信息。
目前业界最先进的 RAG(检索增强生成)系统,在“文档检索”这一步,基本采用的都是图上画的这套“向量+BM25+RRF重排”的标准架构。
分数融合的难题:两种分数不在同一个尺度上,向量检索返回的是余弦相似度(0~1 之间),BM25 返回的是相关性分数(0~正无穷),两者的值域不一样,不能直接相加。你可能会想到做归一化——把两种分数都映射到 0~1 之间,然后再相加或加权平均。但这也有问题:如果某一路检索的分数分布很集中(比如都在 0.8~0.9 之间),归一化后会把微小的差异放大;如果分数分布很分散(比如 0.1~0.9 都有),归一化后会把大的差异压缩。
所以实际工程中,最常用的融合策略不是基于分数,而是基于排名——这就是 RRF(Reciprocal Rank Fusion,倒数排名融合)。
public class MilvusHybridSchemaDemo {
private static final String COLLECTION = "customer_service_hybrid";
private static final String SILICONFLOW_API_KEY = "你的 SiliconFlow API Key";
private static final String EMBEDDING_URL = "https://api.siliconflow.cn/v1/embeddings";
private static final String EMBEDDING_MODEL = "Qwen/Qwen3-Embedding-8B";
private static final Gson GSON = new Gson();
private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();
/** 三种检索模式 */
public enum SearchMode {
DENSE_ONLY, // 纯向量检索
SPARSE_ONLY, // 纯 BM25 检索
HYBRID // Dense + Sparse 混合检索
}
/** 检索参数配置 */
public static class SearchConfig {
public int denseRecallTopK = 20;
public int sparseRecallTopK = 20;
public int finalTopK = 8;
public int nprobe = 16;
public double dropRatioSearch = 0.2;
public int rrfK = 60;
public List<String> outFields = List.of("text");
public ConsistencyLevel consistencyLevel = ConsistencyLevel.BOUNDED;
public static SearchConfig defaults() {
return new SearchConfig();
}
}
public static void main(String[] args) {
MilvusClientV2 client = new MilvusClientV2(ConnectConfig.builder()
.uri("http://localhost:19530")
.build());
createCollectionIfAbsentAndLoad(client);
String query = "订单号 2026012345 的物流状态";
SearchConfig cfg = SearchConfig.defaults();
// 依次跑三种模式做对比
for (SearchMode mode : SearchMode.values()) {
SearchResp resp = runSearch(client, query, mode, cfg);
printSearchResults(resp, mode);
}
}
// ==================== Collection 创建与数据加载 ====================
public static void createCollectionIfAbsentAndLoad(MilvusClientV2 client) {
Boolean exists = client.hasCollection(
HasCollectionReq.builder().collectionName(COLLECTION).build()
);
if (!Boolean.TRUE.equals(exists)) {
// 1) Schema
CreateCollectionReq.CollectionSchema schema = client.createSchema();
schema.addField(AddFieldReq.builder()
.fieldName("id").dataType(DataType.Int64)
.isPrimaryKey(true).autoID(true).build());
schema.addField(AddFieldReq.builder()
.fieldName("text").dataType(DataType.VarChar)
.maxLength(8192).enableAnalyzer(true).build());
schema.addField(AddFieldReq.builder()
.fieldName("text_dense").dataType(DataType.FloatVector)
.dimension(4096).build());
schema.addField(AddFieldReq.builder()
.fieldName("text_sparse").dataType(DataType.SparseFloatVector).build());
schema.addFunction(Function.builder()
.functionType(FunctionType.BM25)
.name("text_bm25_emb")
.inputFieldNames(List.of("text"))
.outputFieldNames(List.of("text_sparse"))
.build());
// 2) Create collection
client.createCollection(CreateCollectionReq.builder()
.collectionName(COLLECTION).collectionSchema(schema).build());
// 3) Index
IndexParam denseIndex = IndexParam.builder()
.fieldName("text_dense")
.indexType(IndexParam.IndexType.AUTOINDEX)
.metricType(IndexParam.MetricType.COSINE).build();
IndexParam sparseIndex = IndexParam.builder()
.fieldName("text_sparse")
.indexType(IndexParam.IndexType.AUTOINDEX)
.metricType(IndexParam.MetricType.BM25).build();
client.createIndex(CreateIndexReq.builder()
.collectionName(COLLECTION)
.indexParams(List.of(denseIndex, sparseIndex)).build());
// 4) Insert demo data
List<JsonObject> rows = Arrays.asList(
buildRow("订单号 2026012345 的物流状态:已发货,预计 1 月 28 日送达,承运商顺丰速运。"),
buildRow("物流规则总述:标准订单 48 小时内发货,偏远地区可能延迟 1-2 天。"),
buildRow("发货时效说明:付款成功后,普通商品 24-48 小时内发货,预售商品以详情页为准。"),
buildRow("异常签收处理:如包裹显示已签收但未收到,请在 48 小时内联系客服核实。"),
buildRow("订单查询入口:登录 APP → 我的订单 → 输入订单号即可查看物流详情。"),
buildRow("退货政策:收到商品 7 天内可申请无理由退货,需保持商品完好。")
);
InsertResp insertResp = client.insert(InsertReq.builder()
.collectionName(COLLECTION).data(rows).build());
System.out.println("插入数据条数:" + insertResp.getInsertCnt());
}
client.loadCollection(LoadCollectionReq.builder()
.collectionName(COLLECTION).build());
System.out.println("Collection 已就绪并加载:" + COLLECTION);
}
// ==================== 三种检索模式 ====================
@SneakyThrows
public static SearchResp runSearch(MilvusClientV2 client,
String queryText,
SearchMode mode,
SearchConfig cfg) {
return switch (mode) {
case DENSE_ONLY -> runDenseOnly(client, queryText, cfg);
case SPARSE_ONLY -> runSparseOnly(client, queryText, cfg);
default -> runHybrid(client, queryText, cfg);
};
}
/** 纯向量检索 */
private static SearchResp runDenseOnly(MilvusClientV2 client,
String queryText,
SearchConfig cfg) throws IOException {
List<Float> queryVec = getEmbedding(queryText);
Map<String, Object> params = new HashMap<>();
params.put("metric_type", "COSINE");
params.put("nprobe", cfg.nprobe);
return client.search(SearchReq.builder()
.collectionName(COLLECTION)
.annsField("text_dense")
.data(Collections.singletonList(new FloatVec(queryVec)))
.topK(cfg.finalTopK)
.outputFields(cfg.outFields)
.searchParams(params)
.consistencyLevel(cfg.consistencyLevel)
.build());
}
/** 纯 BM25 检索 */
private static SearchResp runSparseOnly(MilvusClientV2 client,
String queryText,
SearchConfig cfg) {
Map<String, Object> params = new HashMap<>();
params.put("metric_type", "BM25");
params.put("drop_ratio_search", cfg.dropRatioSearch);
return client.search(SearchReq.builder()
.collectionName(COLLECTION)
.annsField("text_sparse")
.data(Collections.singletonList(new EmbeddedText(queryText)))
.topK(cfg.finalTopK)
.outputFields(cfg.outFields)
.searchParams(params)
.consistencyLevel(cfg.consistencyLevel)
.build());
}
/** 混合检索:Dense + Sparse,RRF 融合 */
private static SearchResp runHybrid(MilvusClientV2 client,
String queryText,
SearchConfig cfg) throws IOException {
List<Float> queryVec = getEmbedding(queryText);
AnnSearchReq denseReq = AnnSearchReq.builder()
.vectorFieldName("text_dense")
.vectors(Collections.singletonList(new FloatVec(queryVec)))
.params("{\"nprobe\": " + cfg.nprobe + "}")
.topK(cfg.denseRecallTopK)
.build();
AnnSearchReq sparseReq = AnnSearchReq.builder()
.vectorFieldName("text_sparse")
.vectors(Collections.singletonList(new EmbeddedText(queryText)))
.params("{\"drop_ratio_search\": " + cfg.dropRatioSearch + "}")
.topK(cfg.sparseRecallTopK)
.build();
HybridSearchReq hybridReq = HybridSearchReq.builder()
.collectionName(COLLECTION)
.searchRequests(List.of(denseReq, sparseReq))
.ranker(new RRFRanker(cfg.rrfK))
.topK(cfg.finalTopK)
.consistencyLevel(cfg.consistencyLevel)
.outFields(cfg.outFields)
.build();
return client.hybridSearch(hybridReq);
}
private static void printSearchResults(SearchResp resp, SearchMode mode) {
System.out.println("\n===== Mode: " + mode + " =====");
List<List<SearchResp.SearchResult>> results = resp.getSearchResults();
for (List<SearchResp.SearchResult> oneQueryResults : results) {
for (int i = 0; i < oneQueryResults.size(); i++) {
SearchResp.SearchResult r = oneQueryResults.get(i);
System.out.println("Top-" + (i + 1) + " score=" + r.getScore() + ", id=" + r.getId());
Object text = r.getEntity() == null ? null : r.getEntity().get("text");
System.out.println(" " + text);
}
}
}
// ==================== 工具方法 ====================
@SneakyThrows
private static JsonObject buildRow(String text) {
JsonObject row = new JsonObject();
row.addProperty("text", text);
List<Float> denseVector = getEmbedding(text);
JsonArray arr = new JsonArray();
for (Float f : denseVector) arr.add(f);
row.add("text_dense", arr);
return row;
}
/** 调用 SiliconFlow Embedding API 生成密集向量 */
private static List<Float> getEmbedding(String text) throws IOException {
JsonObject requestBody = new JsonObject();
requestBody.addProperty("model", EMBEDDING_MODEL);
requestBody.add("input", GSON.toJsonTree(List.of(text)));
Request request = new Request.Builder()
.url(EMBEDDING_URL)
.addHeader("Authorization", "Bearer " + SILICONFLOW_API_KEY)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(
GSON.toJson(requestBody),
MediaType.parse("application/json")))
.build();
try (Response response = HTTP_CLIENT.newCall(request).execute()) {
String body = Objects.requireNonNull(response.body()).string();
if (!response.isSuccessful()) {
throw new IOException("Embedding API 调用失败 http=" + response.code() + ", body=" + body);
}
JsonObject json = GSON.fromJson(body, JsonObject.class);
JsonArray dataArray = json.getAsJsonArray("data");
if (CollUtil.isEmpty(dataArray)) {
throw new IOException("Embedding API 返回 data 为空,原始响应: " + body);
}
JsonArray embeddingArray = dataArray.get(0).getAsJsonObject().getAsJsonArray("embedding");
if (embeddingArray == null) {
throw new IOException("Embedding API 返回 embedding 为空,原始响应: " + body);
}
List<Float> vector = new ArrayList<>(embeddingArray.size());
for (int i = 0; i < embeddingArray.size(); i++) {
vector.add(embeddingArray.get(i).getAsFloat());
}
return vector;
}
}
}
这段代码的关键点:
SearchMode枚举支持三种模式切换,方便对比测试- 纯向量检索用
client.search()+FloatVec,纯 BM25 用client.search()+EmbeddedText,混合检索用client.hybridSearch()+RRFRanker EmbeddedText直接传原文,Milvus 会自动分词并计算 BM25 分数SearchConfig集中管理检索参数,便于调优
用同一个 query:"订单号 2026012345 的物流状态",对比三种检索模式的实际运行结果。
纯向量检索(DENSE_ONLY)
| 排名 | score | 内容 |
|---|---|---|
| Top-1 | 0.840 | 订单号 2026012345 的物流状态:已发货,预计 1 月 28 日送达,承运商顺丰速运。 |
| Top-2 | 0.816 | 订单查询入口:登录 APP → 我的订单 → 输入订单号即可查看物流详情。 |
| Top-3 | 0.573 | 异常签收处理:如包裹显示已签收但未收到,请在 48 小时内联系客服核实。 |
| Top-4 | 0.484 | 发货时效说明:付款成功后,普通商品 24-48 小时内发货,预售商品以详情页为准。 |
| Top-5 | 0.457 | 物流规则总述:标准订单 48 小时内发货,偏远地区可能延迟 1-2 天。 |
| Top-6 | 0.389 | 退货政策:收到商品 7 天内可申请无理由退货,需保持商品完好。 |
纯BM25检索(SPARSE_ONLY)
| 排名 | score | 内容 |
|---|---|---|
| Top-1 | 3.837 | 订单号 2026012345 的物流状态:已发货,预计 1 月 28 日送达,承运商顺丰速运。 |
只返回了 1 条结果,其余 5 条因词汇重叠太少,BM25 打分低于
drop_ratio_search阈值被过滤。
混合检索(HYBRID,RRFk=60)
| 排名 | score | 内容 |
|---|---|---|
| Top-1 | 0.0328 | 订单号 2026012345 的物流状态:已发货,预计 1 月 28 日送达,承运商顺丰速运。 |
| Top-2 | 0.0161 | 订单查询入口:登录 APP → 我的订单 → 输入订单号即可查看物流详情。 |
| Top-3 | 0.0159 | 异常签收处理:如包裹显示已签收但未收到,请在 48 小时内联系客服核实。 |
| Top-4 | 0.0156 | 发货时效说明:付款成功后,普通商品 24-48 小时内发货,预售商品以详情页为准。 |
| Top-5 | 0.0154 | 物流规则总述:标准订单 48 小时内发货,偏远地区可能延迟 1-2 天。 |
| Top-6 | 0.0152 | 退货政策:收到商品 7 天内可申请无理由退货,需保持商品完好。 |
结果分析:
从这组对比中可以观察到几个关键现象:
- 1.score量纲完全不同 :BM25 的分数是 3.8,COSINE 相似度是 0.84,RRF 融合后是 0.033。三种分数不可直接比较,RRF 分数的计算公式是
1/(k+rank),k=60 时 Top-1 的理论上限约为2 × 1/61 ≈ 0.033(两路都排第一时取到最大值) - 2.BM25高精度、低召回 :纯 BM25 只返回了 1 条结果,因为 query 中的"订单号""2026012345""物流""状态"这些 token 只在第一条文本中大量出现,其余文本的词汇重叠太少被
drop_ratio_search=0.2过滤。这正是 BM25 的典型特征——精确匹配能力强,但对语义相近但措辞不同的文本无能为力 - 3.向量检索高召回、语义泛化 :纯向量检索返回了全部 6 条结果,且 Top-2"订单查询入口"虽然不包含"2026012345"这个关键词,但语义上与"查订单物流"高度相关,COSINE 分数达到 0.816。这体现了向量检索的语义泛化能力
- 4.混合检索兼顾两者 :Top-1 精确命中了包含订单号的文本(BM25 的贡献),同时 Top-2 到 Top-6 保留了向量检索的语义排序(向量检索的贡献)。在当前小数据集下,混合检索的排序与纯向量检索一致,但 Top-1的RRF分数(0.0328)显著高于Top-2(0.0161) ,拉开了近一倍的差距——这正是因为 Top-1 在两路检索中都排第一,获得了双倍的 RRF 加分
当前示例数据只有 6 条,三种模式的 Top-1 都是同一条,混合检索的互补优势不够明显。在实际生产环境中,当数据量达到数万甚至数百万条时,纯向量检索容易把语义相近但不相关的结果排到前面,纯 BM25 容易漏掉措辞不同但语义相关的结果,混合检索的优势会更加显著。
重排序(Reranking):对候选结果做精细化排序
1. 为什么需要重排序
混合检索已经能把相关的 chunk 召回来了,为什么还需要重排序?
因为召回阶段(向量检索 / BM25 / 混合检索)追求的是快速召回尽可能多的相关结果 ,但排序不一定精准。打个比方,你在图书馆找书,召回阶段是把可能相关的书都搬到桌子上 ,重排序是仔细翻看每本书,把最相关的几本排到最前面 。
最终给 LLM 的上下文窗口很小,真正关键的是 Top-3 或 Top-5 的排序是否正确。如果 Top-1 是不相关的 chunk,LLM 很可能被误导,生成错误的答案。重排序就是解决这一步——用更强的模型对候选集重新打分,把最相关的结果排到最前面。
2. 重排序的工作原理
重排序的基本流程是:
- 1.初检阶段(向量检索 / 混合检索)从100w文档里面快速召回候选集,比如 Top-20 或 Top-50
- 2.重排序模型逐个评估这个 chunk 和用户问题到底有多相关,给每个候选打分进行精排
- 3.按重排序分数重新排序,取 Top-K(比如 Top-5)作为最终结果
2.1 Bi-Encoder vs Cross-Encoder
这里需要理解两种编码器的区别:
- Bi-Encoder(双编码器):query 和 chunk 分别编码成向量,然后计算向量相似度。这就是 Embedding 模型的工作方式。优点是速度快,可以提前把所有 chunk 编码好存起来,查询时只需要编码 query;缺点是精度有限,因为 query 和 chunk 是独立编码的,无法捕捉它们之间的细粒度交互关系。
- Cross-Encoder(交叉编码器):把 query 和 chunk 拼接在一起(比如
[CLS] query [SEP] chunk [SEP]),一起输入模型,模型能看到 query 和 chunk 的完整交互,输出一个相关性分数。优点是精度更高,能捕捉更细粒度的语义关系;缺点是速度慢,每个 (query, chunk) 对都要过一遍模型。
重排序通常用 Cross-Encoder,因为候选集已经很小了(比如 20~50 个),可以接受慢一点的速度,换取更高的精度。
2.2 为什么不直接用 Cross-Encoder 做检索
你可能会问:既然 Cross-Encoder 精度更高,为什么不直接用它做检索,还要搞两阶段?
因为太慢了。假设你的知识库有 100 万个 chunk,用户提问时,你需要把这 100 万个 chunk 逐个和 query 拼接起来过 Cross-Encoder,这需要 100 万次模型推理,延迟和成本都不可接受。
所以工程上一定是两阶段策略:
- 1.粗检索(Bi-Encoder):快速从 100 万个 chunk 中召回 Top-20 或 Top-50,延迟低,覆盖面广
- 2.精排序(Cross-Encoder):对这 20~50 个候选逐个打分,延迟可接受,精度高
这就是快召回 + 慢精排的核心思想。
这段内容非常硬核且贴近实际工程,它完整地展示了构建一个生产级 RAG(检索增强生成)系统中极其关键的“检索与重排(Retrieve & Rerank)”链路。
为了让你更透彻地理解,我将从底层原理、代码逻辑、架构选型、参数调优四个维度,对你提供的这段内容进行详细拆解和深度延展。
一、 深度拆解:为什么需要 Reranker(重排序)?
在 RAG 流程中,检索通常分为两阶段:粗排(召回) 和 精排(重排序)。
1. 核心概念对比
- 向量检索/BM25(粗排): 就像是“海选”。面对海量数据库(几十万、上百万条文档),它们能用极低的延迟(几十毫秒)捞出最相关的几十条。但它们比较“粗心”,容易被表面的词汇或相似的句式迷惑。
- Reranker(精排): 就像是“终面”。它计算量大、速度慢,所以不能用来扫全库,只能处理粗排送过来的这几十个候选者。Reranker 底层通常是Cross-Encoder(交叉编码器),它会把用户的 Query 和每一个文档拼在一起,进行逐字逐句的深度语义交互计算,因此打分极其精准。
2. 代码解析与亮点(Java 实现)
这段 Java 代码非常标准,有几个工程上的亮点值得注意:
- 轻量级 HTTP 客户端: 使用 OkHttp 和 Gson 直接调用 API,没有引入沉重的第三方 SDK,这在实际业务中非常利于维护。
- 容错处理: if (!response.isSuccessful()) 做了基本的错误拦截,实际生产中这里还需要加上重试机制(Retry)和降级策略(比如 API 挂了,直接返回粗排结果)。
- 面向对象的封装: 定义了 RerankItem 类,保留了原始文档的 index(索引)、score(分数)和 text(文本),最后利用 Java 的 Comparator 根据 score 进行倒序排列。
3. Qwen3-Reranker 的“杀手锏”:Instruction(指令)
普通 Reranker 只能判断“这两段文本相似度多高”。但 Qwen 引入的 instruction 参数改变了游戏规则:
- 痛点: 有时候相关性高的文档,并不是业务想要的文档。比如用户查订单,可能有很多相似的历史订单文本。
- 破局: 传入 "优先排序包含订单号与时间状态的文档",Reranker 就具备了“业务逻辑的偏好”,它会刻意把带有时间戳和订单状态的文档推到最前面。这是大模型时代带来的灵活性。
4. iPhone 16 案例说明了什么?
Query: iPhone 16 Pro Max 拆封后还能退吗
- 粗排可能因为 "iPhone 16 Pro Max" 这个词频极高,把《七天无理由退货说明》(可能未直接写明拆封条件)排到了第一。
- 重排通过深度语义理解,捕捉到了核心痛点词 "拆封"。它准确地将《拆封商品退货规则》提上来,把泛泛而谈的《七天无理由退货说明》降下去。
- 结论: Rerank 不生产新文档,它只是“好钢用在刀刃上”,把最精华的知识塞进大模型有限的上下文窗口里。
二、 架构选型:四种检索策略怎么选?
文中给出的决策表非常经典,这里补充其背后的工程考量:
- 纯向量检索 (Dense Retrieval)
- 原理: 把文字变成多维向量空间里的坐标,算距离。
- 缺点: 对专有名词、数字极其不敏感。搜“订单号 2026012345”,向量可能会给你返回“订单号 2026012346”(因为它们在语义空间里长得太像了)。
- 纯 BM25 (Sparse Retrieval)
- 原理: 传统的关键词词频统计(TF-IDF 的升级版)。
- 缺点: 不懂同义词。搜“番茄”,它绝对找不到只写了“西红柿”的文档。
- 混合检索 (Hybrid: Vector + BM25)
- 黄金搭档: 向量解决“同义词和自然语言语义”问题,BM25 解决“专有名词、编号、型号的精准匹配”问题。通过 RRF(Reciprocal Rank Fusion,倒数排名融合)算法将两路结果合并。
- 适用: 90% 的企业级 RAG 起步首选。
- 混合检索 + Rerank (Pipeline 的终极形态)
- 代价: 增加了一次大模型 API 调用,延迟会增加 200-500ms,且有 token 成本。
- 收益: 解决 RAG 最头疼的“答非所问”问题,对最终答案的准确率提升巨大。
三、 调优秘籍:那些用钱和踩坑换来的经验
文中的第 3 节“检索参数调优”是整篇的精华所在,这里我为你翻译成更通俗的“大白话”:
1. 漏斗模型参数 (The Funnel)
想象一个漏斗,数据量层层递减:
- 全库 (Millions) ->
- Dense Top 20 & Sparse Top 20(分别捞 20 个) ->
- RRF 融合池 (最多 40 个) ->
- Rerank 候选数 (Top 30)(挑出前 30 送去精排,送多了 API 费钱且慢,送少了怕漏) ->
- 最终返回 K (Top 5)(最终喂给 ChatGPT/Claude 等大模型的只有前 5 个,因为给太多大模型会“注意力涣散”)。
2. 最容易犯的错:本末倒置的调优顺序
文中说:“很多团队一上来就调 Reranker,结果其实是召回阶段已经漏掉了关键 chunk...”
- 比喻: 警察抓小偷。召回阶段是在全城圈定 100 个嫌疑人,重排阶段是测谎仪。如果真正的罪犯根本没被抓进那 100 个人里(召回失败),测谎仪再高级也没用。
- 正确做法: 先不管排序结果,看你想找的那段文本,有没有出现在 Top 20 里面?如果没有,你需要优化分块策略(Chunking)、换更好的 Embedding 模型,或者加关键字。确保目标在 Top 20 里面后,再去加 Reranker 把它提拔到 Top 1。
3. 三大核心指标扫盲
为了科学评估系统,业界通常看这三个指标:
- Recall@20(召回率):
- 大白话: 数据库里有正确答案,在前 20 个搜索结果中,正确答案出现了吗?出现了就是 1,没出现就是 0。越高越好,说明“没漏掉”。
- MRR (Mean Reciprocal Rank,平均倒数排名):
- 大白话: 正确答案排在第几名?排第 1,得分 1/1 = 1。排第 2,得分 1/2 = 0.5。排第 5,得分 1/5 = 0.2。这个指标惩罚那些把正确答案排在后面的系统。
- nDCG@10 (归一化折损累积增益):
- 大白话: 有些查询有多个好答案,有些答案“极其相关”,有些“一般相关”。nDCG 会看你是不是把“极其相关”的排在了最上面,“一般相关”的紧随其后。排序越完美,得分越接近 1。
总结
这篇内容实际上勾勒出了一套标准的工业级 RAG 检索架构:
用户提问 -> 向量召回 (Milvus/Faiss) + 关键词召回 (ES/BM25) -> RRF 融合去重 -> Reranker 模型精准重排 -> Top 3 喂给 LLM 生成答案。

Comments NOTHING