Chapter 03
检索与新鲜度:查得准、看得新、换得动
02 设计了数据与租户隔离——这一章管查询与更新:检索质量(混合检索 + 两阶段 rerank)、新鲜度(一致性级别与 segment 状态)、增量更新与换模型,全部建在 02 那套多租户 schema 之上。
本章你将建立的 schema
- 混合检索的三路材料——dense(语义)、sparse(精确词)、BM25 全文——各自抓什么、何时叠加。
- 两阶段排序:先融合(RRF / Weighted)合并召回列表,再交叉编码器(cross-encoder)精排;两者不是一回事。
- 新鲜度的两个独立维度:"能不能看到"(一致性级别)和"看到快不快"(segment 状态)。
- 增量更新(upsert / 删除)与换嵌入模型的蓝绿迁移——以及那条"只换一半就静默返回垃圾"的铁律。
3.1混合检索:dense、sparse、BM25 各抓一类查询
混合检索 = 同一份语料上并行跑多条召回路:dense 向量抓语义、sparse 向量与 BM25 抓精确词;它们补的是彼此的盲区,不是同一种召回的两个参数。
dense 向量(FLOAT_VECTOR,走 COSINE + HNSW)擅长"意思相近"——用户换种说法也能召回。但它对产品型号、错误码、人名、函数名这类字面 token很弱:`BGE-M3` 和"某个嵌入模型"在它眼里距离很近,于是真正要找的那条罕见词反而沉底。sparse 向量(SPARSE_FLOAT_VECTOR,走 IP + SPARSE_INVERTED_INDEX)和 BM25 正好相反——它们按词项命中,精确词一击即中,却读不懂改写。生产语料同时有这两类查询,所以两条路并行、最后融合。
三路材料的分工:dense 抓语义与改写;sparse 抓精确 token(含罕见词、型号、代码标识符);BM25 是 sparse 的"自动挡"——2.5 GA 起,建一个 Function(输入 VARCHAR 文本字段、配 analyzer,输出一个 sparse 字段),灌入的是原始文本而非预算好的向量,查询时由引擎实时算 BM25 分。三者落在同一个 collection 的不同字段上,正是 02 的 RAG schema(面①,含 sparse / text 字段)预留的位置。
比文档深一层:两个容易栽的细节——其一,sparse 向量只支持 IP(内积)度量,L2 和 COSINE 在 sparse 字段上被禁,建表时给 sparse 字段写 COSINE 会直接报错;其二,BM25 不预计算、随插入实时算分——它不像 dense 那样灌库时就把向量定死,而是查询时基于当前词频统计现算,所以语料分布变化会直接反映到打分上,无需重嵌入。
| 方案 | 补的能力 | 代价 / 何时不必 |
|---|---|---|
| dense-only | 语义、改写、跨语言;demo 与纯语义问答够用 | 精确词/型号/代码命中差,罕见词漏召回 |
| dense + sparse | 语义 + 精确 token 双覆盖;生产检索质量的默认底座 | 多一个 sparse 字段与索引;查询要走 hybrid_search |
| dense + BM25 全文 | 同上,但 sparse 由引擎实时算、灌原始文本,省去自己产稀疏向量 | BM25 查询时算分、略增检索开销;需 2.5+ 与 analyzer 配置 |
sparse 字段既可以由你在客户端用 SPLADE/BGE-M3 之类产出稀疏向量后灌入,也可以交给 BM25 Function 在服务端从原始文本实时生成。两条来路最终都落到 SPARSE_FLOAT_VECTOR + IP + SPARSE_INVERTED_INDEX 上,检索路径一致——区别只在"稀疏向量谁来算、何时算"。
与下一节的关系:3.1 把两条路召回成两个列表;下一节决定这两个列表怎么合(融合 rerank),以及合完之后要不要再上一层交叉编码器精排(模型 rerank)。
3.2两阶段 rerank:先融合排名,再逐对精排
两阶段 rerank = 第一阶段把多路召回融合成一个排序(RRF / Weighted),第二阶段用交叉编码器对候选逐对重打分;前者解决"分数怎么合",后者解决"近似召回不够准"。
dense 与 sparse 的分数不在同一量纲——COSINE 落在 [-1,1]、BM25 是无上界的词频分,直接相加毫无意义。第一阶段融合先把它们对齐成一个列表(宽,top-100)。但 ANN 召回本身是近似的、粗排不够精,于是第二阶段让交叉编码器把 query 和每个候选拼在一起重新读一遍,给出真正贴合的相关性分,裁出窄 top-5~10 喂 LLM。第一阶段快而粗,第二阶段慢而精,分工明确。
API 形态:hybrid_search 接收一组 AnnSearchRequest(每个字段一个,带 data / anns_field / param / limit / expr)、一个 rerank 融合器、一个总 limit。融合器二选一:
| 融合器 | 怎么合 | 调参 / 适用 |
|---|---|---|
| RRFRanker | 只用排名(第几位)算分,免归一、scale-free,对量纲差异天然免疫 | 默认参数 k=60 即可起步,无权重要调;不确定时更稳的默认 |
| WeightedRanker | 把各路分数归一后加权求和,需指定 dense / sparse 权重 | 能精调某一路话语权(稀疏权重 ~0.3 常见),但要先归一、对分布敏感、需调参 |
比文档深一层:RRF 不看分数只看名次,所以 dense 的 COSINE 和 sparse 的 BM25 量纲差多大都无所谓——这正是它"默认更稳"的根因。WeightedRanker 给了你旋钮,但旋钮意味着要先把两路分数归一、再凭经验配权重,分布一漂权重就得重调。先用 RRF 跑通,确有某一路系统性偏强偏弱时再换 Weighted 精调。
第二阶段:模型 rerank(交叉编码器)。这一阶段的部署位置在 2.6 有了新选择:
| 位置 | 形态 | 取舍 |
|---|---|---|
| 服务端(2.6 新增) | Function reranker(TEIRanker / vLLM ranker),在 Milvus 内调用打分服务 | 少一次网络往返、候选不出库;但候选字段必须是 VARCHAR、一次只处理一个文本字段 |
| 客户端 | pymilvus[model]:BGERerankFunction(bge-reranker-v2-m3)/ CohereRerankFunction / CrossEncoderRerankFunction | 选模型自由、易换;要自己把候选取回再打分,多一跳延迟与一份算力 |
检索质量上不去,于是把 RRF 换成 WeightedRanker 调权重,又把 dense 权重一路加大——召回里精确词命中却更差了。哪里反了?
展开答案(先停 10 秒)
方向反了。精确词命中靠的是 sparse / BM25 那一路,加大 dense 权重等于压低了负责精确匹配的一路,精确词当然更沉。WeightedRanker 的权重要按"哪类查询命中差"反向调:精确词差→提 sparse 权重(常见 ~0.3 起步往上)。更稳的做法是先回到 RRF——它不看分数只看名次,省掉归一和调参,确认是融合策略问题还是召回本身就没召回到,再决定要不要精调权重。
与下一节的关系:3.2 的两阶段都假设召回宽度(top-100)和精排后宽度(top-5~10)已经定好;下一节就是这两个数字、以及索引层 ef/nprobe 这几个旋钮该怎么拧。
3.3召回旋钮:宽召回喂 reranker,窄上下文喂 LLM
召回旋钮 = 用 ef/nprobe 调"召回率↔延迟"、用检索 top-k 调"喂给 reranker 多宽"、用 final-k 调"进 LLM 上下文多窄";三者各管一段,别混着拧。
ef(HNSW)/ nprobe(IVF)管索引层的搜索努力:调高,遍历更多候选、召回率上升、延迟上升;调低则反之(机制底座见 深潜 03 的 ef/nprobe 旋钮)。检索 top-k管召回宽度,取宽(50~100)好让 reranker 有足够候选去精排。final-k管进 LLM 的上下文宽度,取窄(5~10),既省 token 又避免长上下文里相关信息被稀释。
比文档深一层(最佳实践):系统过载、延迟超标时,先降 ef/nprobe、收窄检索 top-k——这些直接削减检索成本。不要靠扩大 LLM 上下文来掩盖低召回:把更多勉强相关的 chunk 塞进上下文,看起来"信息更全",实则是用 LLM 的钱补检索的债,且相关信息被噪声淹没后答案更差。低召回是检索层的 bug(该召回的没召回),不是上下文不够。先把召回修对,再谈上下文宽度。
"答案不对 → 多喂几条 chunk"是最常见的误修。final-k 从 5 加到 30,召回率没变、噪声却涨了,LLM 在更长的上下文里更容易抓错重点。正确顺序:先查 ef/nprobe 与检索 top-k 是否把对的 chunk 召回了(看 recall 指标,不看上下文长度),再决定 final-k。
与下一节的关系:3.1–3.3 都默认"要查的数据已经在库里、且可见"。下一节回到 01 的面③——刚写入的数据到底何时可见,由一致性级别和 segment 状态共同决定。
3.4新鲜度与一致性:默认 Bounded 不是 read-your-writes
新鲜度 = 刚写入的文档何时能被检索到,由两件独立的事决定:一致性级别(你这次查询能看到多新的视图)和 segment 状态(这条数据建好索引没);默认 Bounded 不保证写完立刻可见。
01 给了结论——默认 Bounded 不是 read-your-writes(见 01 面③)。这里补机制:每条查询携带一个 guarantee timestamp,引擎保证读到不早于该时间点的数据视图(TSO / guarantee ts 的细节见 深潜 04)。一致性级别本质就是这个 timestamp 怎么取——取多旧,决定看到多新。
| 级别 | 看到多新 / 延迟 | RAG 何时用 |
|---|---|---|
| Bounded(默认) | 滞后数秒的视图;非 read-your-writes;尾延迟低 | 离线批量灌库后再查询的常规检索——几秒滞后无所谓 |
| Session | 同一 client 读己写(read-your-writes);延迟介于两者之间 | 刚写入、紧接着要自查的场景——灌库后立刻自测的甜点 |
| Strong | 全局最新视图;尾延迟最高 | 跨 client 强一致读写、对"绝对最新"敏感的少数场景 |
比文档深一层:刚写入的数据先落进 growing segment——没建索引、靠暴力扫(brute-force)命中。所以新鲜度其实是两个正交问题:能不能看到由一致性级别决定(guarantee timestamp 够不够新),看到了快不快由 segment 状态决定(还在 growing 暴力扫,还是已 sealed + indexed)。生产里这两件事常被合并误判成一句"Milvus 写入有延迟"——其实一个是可见性、一个是索引时机,修法完全不同。
RAG 灌库脚本:写入一批文档后,同一进程立刻按内容自查校验——大部分命中,但偶尔几条空。一致性已经从默认 Bounded 改成 Session 了,为什么还偶发空?
展开答案(先停 10 秒)
Session 解决的是"可见性"——同一 client 一定读得到自己刚写的,这一层没问题。偶发空更多出在另一条线:刚写的数据还在 growing segment、靠暴力扫命中,而暴力扫的召回行为与建好索引后未必一致(尤其叠了 filter 时),或写入尚未对该查询完全可见的边界时刻。稳妥校验:写入后显式 flush 等数据落 sealed、或在校验前留出索引就绪的时间,再用 Session 自查。可见性(Session 已修)和 segment 状态(需 flush/等索引)是两件事——这正是上图要分两排的原因。
与下一节的关系:3.4 管"写入何时可见";下一节管"写入本身怎么做"——增量 upsert、删除回收,以及最危险的换模型迁移。
3.5增量更新与换模型:upsert、删除回收、蓝绿迁移
增量更新 = 文档变了怎么改库(upsert / 删除)、模型变了怎么换库(蓝绿迁移);其中最致命的一条是:换嵌入模型时 query 和 doc 必须同时重嵌入,否则静默返回垃圾、不报任何错。
upsert = 按主键先删后插。删除不是就地抹掉,而是写一条 tombstone(删除标记)——2.5+ 落进专门的 L0 segment,旧数据要靠 compaction + GC 才真正回收(删除占比约超过 10~20% 时触发一轮)。所以高频 upsert 会短期内堆积 tombstone 与冗余行,检索成本上升直到下一轮 compaction。删整篇文档(一篇被切成多 chunk)则用 parent_doc_id 之类的标签批量删——一次 expr 删掉同篇所有 chunk。
换嵌入模型:维度一变就是新 collection。向量维度(dim)在建表时即固定,换一个输出维度不同的模型,无法原地改 dim——必须新建 collection。标准做法是蓝绿迁移:建 v2(新 dim)→ 离线把全量语料用新模型重嵌入、回填进 v2 → 流量切到 v2 → v1 暂留作回滚。
| 变更 | 怎么做 | 要点 |
|---|---|---|
| 改单条 chunk | upsert(按 pk 删+插) | 删除是 tombstone,靠 compaction+GC 回收(约删除>10~20% 触发) |
| 删整篇文档 | 按 parent_doc_id 批量 delete |
一次 expr 删掉同篇所有 chunk,别逐 chunk 删 |
| 换嵌入模型(dim 变) | 新建 v2 collection + 蓝绿回填切流 | dim 建表即固定;query 与 doc 必须同时重嵌入,否则静默错 |
| A/B 两套嵌入 | 多向量字段(同 collection 存两套嵌入) | 单 collection 多向量字段(≤10,自 2.4),免双写双库 |
嵌入模型决定向量所在的语义空间。doc 用新模型、query 仍用旧模型(或反过来),两边向量落在不同空间,相似度计算照样跑、照样返回 top-k,但全是噪声且不报任何错。这是 RAG 换模型最隐蔽的事故。两条护栏:① 切流时强制 query 与 doc 同源同模型;② 给每条向量打 model_version 标签,检索端校验版本一致,发现混用立刻报警。
与下一节的关系:3.1–3.5 定的是单租户视角下的检索与更新行为;下一节快速收一下规模与成本的旋钮,再把全章串成一个生产检索器。
3.6规模与成本:按内存占用选索引
规模与成本 = 在召回率、延迟、内存/算力之间挑索引与部署形态;主线只有一条——按"语料能放进多少内存"选索引族。
向量索引的成本主要是内存。三档典型选择:全量能进内存 → HNSW(召回与延迟都最优);只能放约 ¼ 进内存 → DiskANN(其余在盘上,尾延迟仍稳,大规模下省算力);想再省内存 → IVF_SQ8 + mmap(量化 + 内存映射省约 70% 内存,代价是召回降约 2~3%)。详细选型矩阵见 深潜 05 的选型。
部署形态:托管(Zilliz Cloud)省去运维、按量扩缩,适合不想自建集群的团队;自建在规模够大、对成本与数据驻留有要求时更划算——阈值取决于团队运维能力与数据量。还有一档兜底:语料很小、且数据已经在 Postgres 里,pgvector 往往比单独引一套 Milvus 更省心,不必为几万条向量上分布式。
先答一个问题——"全量向量占多少内存、预算放得下多少"。放得下全量用 HNSW;放不下但要稳尾延迟用 DiskANN;预算极紧、能接受召回掉 2~3% 用 IVF_SQ8+mmap。这条主线先定,再去深潜 05 对照过滤比、QPS、更新频率等次级因素。
3.7综合:把一个生产检索器从头拍一遍
综合 = 承接 02 的多租户 schema,把 3.1–3.5 的每个决策串成一条检索链路,并看清它们如何相互牵制——一个旋钮动了,下游几个跟着变。
场景:多租户企业知识库 RAG,建在 02 的 partition-key 多租户 schema 上,每条查询强制带 tenant_id filter(面①是安全边界)。用户既问语义问题,也查产品型号、错误码这类精确词。以下是逐项决策:
| 决策点 | 拍板 | 依据 / 牵制 |
|---|---|---|
| 检索栈 | dense + BM25 全文 | 有精确词/型号 → 必须叠 sparse 一路;BM25 省去自产稀疏向量(3.1) |
| 融合 | RRFRanker 起步 | 免归一、scale-free、无权重要调;确有偏科再换 Weighted(3.2) |
| 精排 | 上交叉编码器,候选字段 VARCHAR 走服务端 reranker | ANN 粗排不够准;服务端少一跳,但要求候选 VARCHAR(3.2) |
| 召回宽度 | 检索 top-k≈100,final-k≈8 | 宽喂 reranker、窄进 LLM;过载先降 k/ef 而非扩上下文(3.3) |
| 一致性 | 常规检索 Bounded,灌库自查 Session | 离线灌库后查容忍几秒滞后;写完即查用 Session(3.4) |
| 文档更新 | 改 chunk 用 upsert,删整篇按 parent_doc_id 批删 |
注意 tombstone 堆积与 compaction 回收节奏(3.5) |
这些决策不是六张独立的票。面① 的 tenant_id filter 把过滤比拉高——按 01 面④与 02 的过滤比↔索引,高过滤比会饿死 HNSW 的图遍历,逼你重新审视索引选择(3.6)和召回旋钮(3.3):往往要把 ef 调高补召回、或对高选择性租户换 IVF 类索引。同理,换模型(3.5)会推翻已建好的索引、连带重定一致性窗口。一处动,链路上几处跟着重拍——这正是 04 章辨析题要练的"牵一发动全身"。
§本章 self-check
先合上教程,把答案写下来再展开对照。
- dense、sparse、BM25 三路召回各擅长抓哪类查询?为什么 sparse 字段只能用 IP、不能用 COSINE?
- 「融合 rerank(RRF/Weighted)」和「模型 rerank(交叉编码器)」分别解决什么问题?RRF 为什么"默认更稳"?
- 插入后用 Session 自查仍偶发空,可见性已经修了,那剩下的原因在哪一层?怎么修?
- (设计题)换嵌入模型时,为什么不能原地改 dim、且必须 query 与 doc 同时重嵌入?只换一半会怎样、怎么防?
答案(先做完再展开)
- dense 抓语义/改写、sparse 抓精确 token/罕见词、BM25 是 sparse 的服务端自动挡(灌原始文本、查询时算分)。sparse 向量在 Milvus 里只支持 IP 度量,L2/COSINE 被禁,建表写 COSINE 会报错。
- 融合 rerank 把多路不同量纲的分数合并成一个排序(第一阶段,宽 top-100);模型 rerank 用交叉编码器逐对重打分、纠正 ANN 粗排(第二阶段,窄 top-5~10)。RRF 只看名次不看分数,免归一、对量纲差异免疫,所以默认更稳。
- 剩下的在 segment 状态层:刚写的数据在 growing 段被暴力扫,召回行为与建好索引后未必一致。修法:写入后
flush等落 sealed、或等索引就绪再校验。可见性(Session 管)和索引时机(flush/等索引管)是两件事。 - dim 建表即固定,新模型输出维度不同就无法原地改,只能新建 collection。query 与 doc 必须同模型,否则两套向量落在不同语义空间,相似度照算但全是噪声且不报错。防:切流时强制同源同模型 + 给向量打
model_version标签、检索端校验一致。
一个高过滤比租户,把整条链路逼着重拍
承接 3.7 的检索器:某大租户启用了"仅本部门 + 最近 7 天 + 当前用户有权限"三层 filter,叠上 tenant_id 后过滤比极高(通过的行不到 1%)。线上现象:这个租户的召回率从 90% 掉到个位数,延迟也飙。请按本章 + 01/02 的牵制关系,说出至少三处要联动调整的决策——索引层、召回旋钮、检索栈各动什么,以及为什么单调一处不够。
提示(卡住再展开)
根因是 01 面④的召回塌陷:极高过滤比饿死 HNSW 图遍历。联动点——①索引层(3.6):对这类高选择性租户考虑 IVF 类索引或可迭代过滤,HNSW 在稀疏连通图上走不动;②召回旋钮(3.3):临时调高 ef 补召回,但延迟会涨,需与过载降 ef 的原则权衡;③检索栈(3.1):精确词靠 sparse/BM25 那路,filter 过严时这一路命中比 dense 更稳,可调整融合权重侧重 sparse;④确认 filter 字段都建了标量索引(02 §2.4),否则退化全表扫。单调一处不够,因为过滤比同时压在"索引能否遍历"和"两路召回各剩多少"上——这就是决策相互牵制的实例。