主题
混合检索与多租户
一句话:生产检索很少只靠一路向量,通常是「稠密召回语义、稀疏召回关键词、标量过滤范围,再融合排序」;多租户则决定这些数据在物理上怎么隔。
适用版本:Milvus 2.6.x,pymilvus 2.5+。
为什么需要
- 用户搜「ERR_CONN_RESET」,稠密向量能理解「连接被重置」,但对这种错误码往往不如关键词精确。
- 用户搜「退款」,BM25 招不回「七天无理由退货」,因为它不懂同义。
- 企业场景必须带
tenant_id、权限、时间范围过滤,否则召回的是别人的数据。
单路召回必然在某类查询上失手,所以生产检索的基本形态是多路召回 + 融合 + 过滤。
核心机制
两路召回怎么并
- 稠密路:语义相似,负责「意思对但字不同」。
- 稀疏路:词项匹配,负责专有名词、错误码、编号、人名。
- 过滤:在召回前缩小范围,不是事后删结果。
融合:RRF 与加权
| 方式 | 依据 | 优点 | 风险 |
|---|---|---|---|
| RRF(Reciprocal Rank Fusion) | 只看名次,score = Σ 1/(k + rank),k 常用 60 | 不需要分数可比,鲁棒 | 丢掉了分数差距信息 |
| WeightedRanker | 各路分数加权 | 可调业务偏好 | 余弦分数和内积分数不可比,必须先归一化 |
默认先用 RRF:两路分数量纲不同,直接加权经常是负优化。
标量过滤为什么会拖慢检索
过滤有两种执行方式:
- 先过滤再检索:候选集小,图索引的邻居可能大量被过滤掉,图的连通性被破坏,召回下降。
- 先检索再过滤:速度快,但 topK 可能被过滤光,只能不断放大
limit。
Milvus 会在段级别做裁剪,但高选择性过滤(命中率低于 1%)仍然要小心。可用的手段:
- 给过滤字段建标量索引(
INVERTED、BITMAP、STL_SORT),减少扫描。 - 把租户维度做成 partition key,直接做分区裁剪。
- 适度调大
ef/nprobe,给过滤留出候选余量。 - 过滤极强时改用「先查出主键,再按主键取向量」的两段式。
多租户的四种做法
| 做法 | 隔离 | 租户上限 | 适用 |
|---|---|---|---|
| 每租户一个 Database | 最强 | 少(几十) | 大客户、合规要求 |
| 每租户一个 Collection | 强 | 中(几百) | 租户数可控、schema 可不同 |
| Partition Key | 中(共享 collection) | 多(默认上限 1024,可调,以官方 Limits 为准) | SaaS 主流做法 |
| 普通标量字段过滤 | 弱(逻辑隔离) | 几乎无限 | 租户极多、隔离要求低 |
Partition Key 的本质是:按该字段哈希到固定数量的物理分区,查询时只要带上该字段的等值条件,就能只扫一个分区。
注意:partition key 必须出现在过滤条件里,且是等值匹配,否则退化成全量扫描。热点大租户会把某个分区打爆,这种情况要单独拆 collection。
BM25:让 Milvus 自己生成稀疏向量
2.5 起可以在 schema 里声明 Function,写入纯文本,由 Milvus 分词并生成 BM25 稀疏向量,不需要额外部署稀疏编码器。
代价是分词器与语言绑定在 collection 上,换语言或换分词规则要重建;k1、b 等参数也影响效果。
最小上手
python
from pymilvus import (MilvusClient, DataType, Function, FunctionType,
AnnSearchRequest, RRFRanker)
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("tenant", DataType.VARCHAR, max_length=64, is_partition_key=True)
schema.add_field("text", DataType.VARCHAR, max_length=4096, enable_analyzer=True)
schema.add_field("dense", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
# 写入 text 时自动生成 sparse(BM25)
schema.add_function(Function(name="bm25", function_type=FunctionType.BM25,
input_field_names=["text"],
output_field_names=["sparse"]))
idx = client.prepare_index_params()
idx.add_index(field_name="dense", index_type="HNSW", metric_type="COSINE",
params={"M": 16, "efConstruction": 200})
idx.add_index(field_name="sparse", index_type="SPARSE_INVERTED_INDEX",
metric_type="BM25")
client.create_collection("rag", schema=schema, index_params=idx)
client.insert("rag", [{"id": 1, "tenant": "acme", "text": "七天无理由退货",
"dense": [0.1] * 768}]) # 不传 sparse
reqs = [
AnnSearchRequest(data=[query_vec], anns_field="dense", limit=20,
param={"metric_type": "COSINE", "params": {"ef": 128}}),
AnnSearchRequest(data=["退款怎么处理"], anns_field="sparse", limit=20,
param={"metric_type": "BM25"}),
]
res = client.hybrid_search(
"rag", reqs, ranker=RRFRanker(60), limit=10,
filter='tenant == "acme"', output_fields=["text"],
)稀疏字段的写入格式是 {维度下标: 权重};用 BM25 Function 时不需要手写。
生产动作
- 每路
limit大于最终limit:常见 3–5 倍,例如每路 50、融合后取 10;太小会让融合没有选择空间。 - RRF 的
k=60是经验值:它压平头部差距;想更看第一名就调小,想更稳就调大。 - 过滤字段必须建索引:高频过滤字段用
INVERTED,低基数字段(状态、类型)可用BITMAP。 - 租户字段用 partition key:并在所有查询里强制带上
tenant == ...,可在 Proxy 层统一注入,避免漏写。 - 监控每路的召回贡献:分别统计稠密路和稀疏路命中的结果占比,长期某一路不贡献就说明它在该语料上无效。
- 变更分词器或 BM25 参数 = 重建 collection:稀疏向量是写入时生成的,改配置不会回溯旧数据。
边界
- 融合不能弥补单路召不回的文档:如果两路都没进候选,RRF 也变不出来;问题在切分或 embedding。
- partition key 不是无限分片:分区数有上限,超了会报错;租户数超过上限时改用 DB/Collection 拆分。
- 跨分区查询会变慢:不带租户条件的运营侧查询会扫全部分区,应走独立的只读副本或离线链路。
- BM25 依赖分词质量:中文需要合适的分词器,错误码、缩写要评估是否被切碎。
- 标量过滤与 ANN 的交互因版本而异:升级后要重跑召回评测,不能假设行为不变。
讲清楚
单一向量召回在真实语料上一定会漏:语义模型对专有名词和编号不敏感,关键词检索又不懂同义。所以生产上做两路召回,稠密管语义、稀疏管精确匹配,再用 RRF 按名次融合。RRF 的好处是不要求两路分数可比,这在余弦和内积混用时特别重要。
多租户决定数据怎么隔。租户少就用独立 database 或 collection;租户多就用 partition key,按租户哈希分区,查询必须带租户等值条件才能裁剪。热点租户要单独拆出去,否则一个分区会拖垮整体。
最容易踩的坑是过滤:高选择性条件会破坏图索引的连通性,召回悄悄下降。解决办法是先分区、再给过滤字段建标量索引,并适当放大
ef。