搜索系统的技术选型很少输在"哪个引擎更快",多半输在默认了什么:默认索引同步是可靠的、默认相关性有人调、默认多租户过滤在正确的位置。等这些默认被现实推翻,改造成本已经比选型成本高一个数量级。
这篇不给压测数字,只给判断链:五个决策点 → 自建/托管 → 分层检索 → 融合 → 同步 → 调优组织 → 权限 → 容量 → 演进。
1. 五个决策点
| 决策点 | 需要回答的问题 | 一票否决项 |
|---|---|---|
| 索引由谁维护 | 写入路径谁控、映射谁改 | 需要频繁改 mapping 但无人管 |
| 查询在哪执行 | 应用侧拼 DSL 还是网关收口 | 客户端能直接连集群 |
| 相关性怎么调 | 谁定义"好"、怎么验证 | 没有标注集也没有点击数据 |
| 容量怎么扩 | 分片、副本、冷热 | 数据量增长无预估 |
| 成本谁承担 | 人力 vs 账单 | 团队无检索运维经验 |
这五个问题里,相关性调优的组织问题最容易被低估:技术上 RRF 与加权都能写,但没有"好"的定义,任何融合参数都只是猜。
2. 自建与托管
| 维度 | 自建(ES/OpenSearch) | 托管服务 |
|---|---|---|
| 运维负担 | 高:分片、快照、升级、GC 调优 | 低:升级与备份由厂商担 |
| 版本控制 | 完全自主,可插插件 | 受限于厂商支持版本 |
| 弹性 | 需自建扩缩容流程 | 通常按规格档位伸缩 |
| 单价 | 硬件 + 人力,人力常被漏算 | 按厂商报价核对,含溢价 |
| 数据主权 | 数据在自有环境 | 取决于区域与合规条款 |
| 故障归因 | 自己查,能查到内核 | 黑盒,靠工单 |
# 自建的隐性成本项:这些不写进预算就会被低估
- 快照恢复演练(每季度,需独立集群验证)
- 版本升级(大版本通常要求滚动重启 + 重索引)
- 映射变更(reindex + 别名切换,需双倍磁盘)
- 容量水位(堆不超过物理内存一半的惯例)
决策规则很朴素:检索不是核心竞争力、团队没有专职检索运维,就不要自建。托管贵在账单,自建贵在人;如果人力不可替代(只有一个人懂),自建的真实成本是"这个人不能休假"。
3. 检索分层:每层只解决一类问题
| 层 | 解决 | 技术选择 | 失效场景 |
|---|---|---|---|
| 关键词 | 精确词、术语、编号 | BM25(ES/Lucene) | 同义词、错别字、跨语言 |
| 结构化过滤 | 权限、时间、状态 | filter 上下文(不参与打分) | 过滤字段未建索引 |
| 向量 | 语义相似、改写问法 | HNSW/IVF,模型见下 | 精确编号、否定句 |
| 重排 | 前 N 条精排 | cross-encoder 或规则 | 延迟预算不够 |
{"query": {"bool": {
"must": [{"match": {"title": {"query": "退款流程", "boost": 2}}}],
"filter": [{"term": {"tenant_id": "t_42"}},
{"range": {"updated_at": {"gte": "now-2y"}}}]
}}, "size": 50}
要点:过滤条件放 filter 上下文,既不参与打分也能被缓存;放 must 里会污染相关性分数,导致"过滤越严、排序越怪"。向量层与关键词层各自召回,交给融合层处理,不要让向量层承担精确匹配。
4. 融合策略
| 策略 | 做法 | 优点 | 失效场景 |
|---|---|---|---|
| RRF | 按名次取 1/(k+rank) 求和 | 无需分数可比、鲁棒 | 两路召回质量差太多时被拖累 |
| 加权 | 归一化后线性组合 | 可调、可解释 | 分数分布随查询漂移 |
| 级联 | 关键词召回 → 向量扩召回 → 重排 | 成本可控 | 首层漏召回则全漏 |
def rrf(rank_lists, k=60, weights=None):
scores = {}
for i, ranks in enumerate(rank_lists):
w = (weights or [1] * len(rank_lists))[i]
for r, doc in enumerate(ranks, start=1):
scores[doc] = scores.get(doc, 0) + w / (k + r)
return sorted(scores, key=scores.get, reverse=True)
RRF 的价值在于绕开分数不可比:BM25 分数与余弦相似度量纲不同,归一化又会随查询变化。它默认两路召回都"大致可信",所以当向量召回在短查询上明显更差时,RRF 会把噪声抬进结果——这时该做的是分流(短查询只走关键词),而不是调 k。
5. 索引同步的三种模式
| 模式 | 一致性 | 复杂度 | 适用 |
|---|---|---|---|
| 双写 | 最终一致,取决于重试 | 低,但易漏写 | 写少读多、可容忍秒级延迟 |
| CDC(binlog/变更流) | 最终一致,有顺序保证 | 中,需处理幂等与回放 | 主库是唯一事实源 |
| 定时全量 | 周期性一致 | 低,但窗口内陈旧 | 小数据量、可接受小时级延迟 |
双写的三个典型漏洞
1. 写库成功后进程崩溃 -> 索引缺失,且无人重试
2. 先写索引后写库 -> 库回滚,索引留下脏文档
3. 并发双写同一文档 -> 旧值覆盖新值(需版本号或时间戳兜底)
CDC 是多数中大型系统的默认答案,因为它把"同步"变成可重放的流:出错能重放,能补历史,能并行重建索引。代价是要有幂等消费(按主键 + 版本覆盖)和 lag 监控,否则你会得到"搜索结果里有一条三小时前的旧价格"。
6. 相关性调优的组织问题
| 问题 | 现实 | 应对 |
|---|---|---|
| 谁定义"好" | 没人,或产品一个人拍 | 建 rubric:相关性分级 0–3 |
| 标注成本 | 每条分钟级,量级决定预算 | 分层抽样,只标争议与头部查询 |
| 离线指标 | NDCG/MRR 可算 | 与线上点击率常有落差 |
| 线上指标 | 点击、零结果率、改写率 | 受 UI 与位置偏置污染 |
# 离线评估:先有分级标注,再谈调参
def ndcg(ranked, labels, k=10):
dcg = sum((2 ** labels.get(d, 0) - 1) / math.log2(i + 2)
for i, d in enumerate(ranked[:k]))
ideal = sorted(labels.values(), reverse=True)[:k]
idcg = sum((2 ** l - 1) / math.log2(i + 2) for i, l in enumerate(ideal))
return dcg / idcg if idcg else 0.0
离线涨、线上不涨的三个常见原因:标注集只覆盖头部查询;线上有位置偏置(第一条被点是因为它在第一条);UI 改了但评估没跟着改。所以相关性调优至少要有一个离线集 + 一个线上对照实验,缺一个就会自欺。
7. 多租户与权限过滤的位置
| 位置 | 做法 | 风险 |
|---|---|---|
| 索引层(每租户独立索引) | 物理隔离 | 索引数爆炸、集群状态压力 |
| 索引层(共享索引 + 路由字段) | routing 到固定分片 | 路由值设计错则热点 |
| 查询层(filter) | 强制注入 tenant_id | 漏注入即泄漏,需框架兜底 |
| 应用层(结果后过滤) | 查完再筛 | 分页数量失真、深分页放大 |
{"query": {"bool": {
"filter": [
{"terms": {"acl_group": ["g_sales", "g_all"]}},
{"term": {"tenant_id": "t_42"}}
]
}}}
铁律:过滤条件必须由框架注入,不能由业务代码手写。手写的下场是某个新接口忘了加,泄漏一次就够上新闻。后过滤(查完再删)还会让"第 2 页只有 3 条"这种诡异现象出现,因为每页被裁掉的数量不可预知。
8. 容量与成本
| 项 | 影响 | 典型量级判断 |
|---|---|---|
| 分片数 | 并行度与元数据开销 | 单分片数十 GB 量级较稳,按实际压测定 |
| 副本数 | 可用性与写入放大 | 至少 1 副本;写入重的场景副本很贵 |
| 冷热分层 | 存储成本 | 历史数据降副本、缩分片、转只读 |
| 向量维度 | 存储与内存 | 768/1024 维常见;维度翻倍内存近似翻倍 |
| 重排模型 | 延迟 | 只对前 N(数十条)做,不要全量 |
向量存储粗算(量级示意,按实际精度与量化方式核对)
N 条 × D 维 × 4 字节(float32) × (1 + 副本) × 索引开销系数
1,000,000 × 768 × 4B ≈ 3.0 GB 原始向量,HNSW 图结构另有可观开销
向量层的成本不在磁盘,在内存:HNSW 要常驻才能低延迟。要做量化(int8/PQ)就得接受召回率损失,这个取舍必须用评估集验证,不能凭感觉。
9. 演进路径
| 阶段 | 形态 | 触发下一步的信号 |
|---|---|---|
| 一 | 单集群,单索引 | 查询延迟随数据量上升 |
| 二 | 读写分离(独立协调/查询节点) | 写入影响查询 |
| 三 | 关键词 + 向量混合 | 语义召回不足,用户改写多 |
| 四 | 加重排与语义层 | 前 10 条质量成为瓶颈 |
每个阶段的退出条件要写清楚,否则会为了"架构先进"提前上混合检索,最后维护两套召回却没有评估集,调参全靠直觉。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 结果里有已删除文档 | 双写漏删 / CDC lag | 加删除事件、监控 lag |
| 分页越翻越慢 | 深分页 from+size | 改 search_after |
| 加了 filter 排序变怪 | 条件写在 must 里参与打分 | 移到 filter |
| 集群 yellow 不恢复 | 副本无法分配、磁盘水位 | 查 allocation explain |
| 同义词生效但相关性下降 | 同义词扩展污染打分 | 用 search analyzer 单独控 |
| 向量召回全是近似重复 | 文档切片过碎 | 调 chunk 粒度与去重 |
11. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 数据量小(万级)、查询简单 | 数据库 LIKE/全文索引 | 一套系统省掉同步链路 |
| 只需按字段精确过滤 | 数据库索引 | 搜索引擎的优势用不上 |
| 强事务一致要求 | 主库查询 + 缓存 | 索引天然最终一致 |
| 查询 QPS 很低 | 直接查库 + 缓存 | 集群空闲成本高 |
| 只需排序不要相关性 | 数据库 ORDER BY | 无需维护索引 |
搜索集群的门槛不是数据量,而是是否需要相关性排序 + 多字段组合 + 高并发检索三者同时成立。只满足一项时,数据库通常更划算:少一条同步链路,就少一整类一致性 bug。
12. 自测
- 五个决策点里,哪一项缺失会让后面所有调优都失去依据?
- 自建与托管的真实成本差异主要体现在哪一项上?
- 过滤条件为什么必须放
filter上下文? - RRF 解决的是什么问题?它在什么情况下会把噪声抬进结果?
- 双写的三个典型漏洞分别是什么?CDC 为什么能缓解?
- 权限过滤为什么必须由框架注入,而不是业务代码手写?