跳到正文

混合检索与多租户 ​

一句话:生产检索很少只靠一路向量,通常是「稠密召回语义、稀疏召回关键词、标量过滤范围,再融合排序」;多租户则决定这些数据在物理上怎么隔。

适用版本: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:两路分数量纲不同,直接加权经常是负优化。

标量过滤为什么会拖慢检索 ​

过滤有两种执行方式:

  1. 先过滤再检索:候选集小,图索引的邻居可能大量被过滤掉,图的连通性被破坏,召回下降。
  2. 先检索再过滤:速度快,但 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。

把一门技术讲透到既懂也会用