"同一套流程,上个月还准,现在明显不行了"——这类抱怨的根因几乎从不是模型变笨,而是索引里的向量和查询用的向量不再是同一个坐标系。向量检索对坐标系极其敏感:文本看起来没变,向量却可能整体偏移,于是最近邻全错。
漂移是四种来源的叠加,且每一种都能单独把 recall 打掉一半。
1. 四种漂移来源
| 来源 | 触发动作 | 变化的东西 | 症状 |
|---|---|---|---|
| embedding 模型换代 | 换模型、换厂商、换维度 | 向量空间整体 | 一次性断崖,旧文档全错 |
| 语料分布变化 | 新增业务线、引入新术语 | 查询与文档的分布 | 渐进式,新领域先变差 |
| chunk 策略改动 | 改大小、改分隔符、改重叠 | 每个 chunk 的语义边界 | 部分问题答不出来,答案残缺 |
| 索引未重建 | 只灌了新数据没回灌旧数据 | 新旧向量混在一个索引 | 新旧文档"两个世界" |
第三种最容易被当成"没调好",因为代码 diff 只有一行 chunk_size=800 → 1200。
2. 硬约束:同模型不同版本也不可混
这是最容易违反的一条。即使维度没变,不同版本的 embedding 权重不同,产出的向量不在同一空间。 余弦相似度在跨空间时毫无意义,不是"差一点",是随机。
| 情况 | 能混吗 | 为什么 |
|---|---|---|
| 同模型同版本,同维度 | 能 | 同一坐标系 |
| 同模型不同版本(如 v1 → v2) | 不可 | 权重不同,空间旋转 |
| 同厂商不同大小(small / large) | 不可 | 维度或权重不同 |
| 同维度不同厂商 | 不可 | 空间独立训练 |
| 文本预处理不同(分词/归一) | 不可 | 输入本身变了 |
对策不是"记得别混",而是让混不了成为结构上的不可能:
EMBED_VERSION = "text-embed-v3" # 与模型厂商的版本号对齐,按实际核对
CHUNK_VERSION = "c4-800-120"
INDEX_NAME = f"rag_docs__{EMBED_VERSION}__{CHUNK_VERSION}".replace(".", "_")
# 查询时必须显式拿到索引名,不允许"写死一个名字"
def resolve_index(active_alias: str) -> str:
return es.indices.get_alias(name=active_alias)["alias"]
把 embedding 版本写进索引名,而不是写进元数据字段。理由是:元数据字段可以被写错,索引名写错会直接 404。
3. 检测漂移:两条腿走路
离线靠评测集,线上靠行为指标。缺一条都会漏。
| 手段 | 指标 | 判定阈值(自行标定) | 反应速度 |
|---|---|---|---|
| 离线评测集 | recall@10 相对基线下降 | > 5 个百分点 | 慢,可提前 |
| 线上点击率 | top3 文档的打开率 | 相对下降 > 20% | 中 |
| 追问率 | 同一会话二次提问比例 | 上升 > 15% | 中 |
| 拒答率 | 触发无答案判定的比例 | 突增 | 快 |
| 向量范数/均值 | 全库向量统计 | 分布位移 | 快,可做守护 |
import numpy as np
def drift_report(base_stats, cur_stats, z_thresh=3.0):
"""对每个维度比对均值与标准差,做多变量位置偏移的粗筛。"""
rows = []
for i, (mu0, sd0, mu1) in enumerate(zip(base_stats["mu"], base_stats["sd"],
cur_stats["mu"])):
z = abs(mu1 - mu0) / (sd0 or 1e-9)
if z > z_thresh:
rows.append({"dim": i, "z": round(z, 2), "base_mu": float(mu0)})
return sorted(rows, key=lambda r: -r["z"])[:10]
典型输出形态(示意):
[{'dim': 312, 'z': 7.41, 'base_mu': -0.021},
{'dim': 88, 'z': 5.03, 'base_mu': 0.017}]
如果一次监控里出现大量维度同时高 z,基本可以确认模型或预处理被换了,而不是数据本身变了。
4. 重建索引:双索引 + 别名切换
重建必须做到"可回滚、不停服、可对比"。三种做法里只有一个合格:
| 方案 | 停机 | 可回滚 | 可对比 | 结论 |
|---|---|---|---|---|
| 原地删除重建 | 长 | 否 | 否 | 不可用 |
| 原地逐条覆盖 | 无 | 中途状态不可回滚 | 否 | 危险 |
| 双索引 + 别名切换 | 无 | 是 | 是 | 采用 |
import time
def rebuild_with_cutover(new_index: str, alias: str, backfill, es):
if es.indices.exists(index=new_index):
es.indices.delete(index=new_index)
# 1) 先复制旧索引的 settings/mappings(只取结构,不带数据)
es.indices.create(index=new_index, settings=SETTINGS, mappings=MAPPINGS)
# 2) 回灌,按批次写入,记录进度
total = 0
for batch in backfill(): # 生成器,每次吐一批 (id, text)
es.bulk(operations=batch_ops(batch, new_index))
total += len(batch)
print(f"backfilled {total}")
es.indices.refresh(index=new_index)
# 3) 离线对比两套索引的 recall,达标才切
if not recall_ok(new_index, baseline=MIN_RECALL):
raise SystemExit("新索引未达标,放弃切换")
# 4) 原子切别名(一次 remove + add,中间无真空期)
es.indices.update_aliases(actions=[
{"remove": {"index": "*", "alias": alias}},
{"add": {"index": new_index, "alias": alias}},
])
print("alias switched to", new_index, "at", time.strftime("%H:%M:%S"))
关键在第三步:先对比再切。切换本身是一秒的事,但如果没有"新索引 recall ≥ 基线"这道闸,切换就是把事故推上前台。
别名切换后旧索引不要立刻删,保留一份能支撑一次回滚的时间窗口(视写量与磁盘决定,常见 3–7 天):
# 回滚就是再切一次别名
curl -s -X POST "http://ES_HOST:9200/_aliases" -H 'Content-Type: application/json' -d '{
"actions": [
{"remove": {"index": "rag_docs__v3__c4", "alias": "rag_docs"}},
{"add": {"index": "rag_docs__v2__c3", "alias": "rag_docs"}}
]}'
5. 版本号写在哪:三处都要
| 位置 | 作用 | 缺失的后果 |
|---|---|---|
| 索引名 | 结构上隔离 | 新旧向量混写 |
文档元数据 embed_version |
单条排查 | 难以定位"哪些是旧的" |
| 线上请求上下文 / 日志 | 归因 | 指标变差时难以区分是哪次发布 |
{
"doc_id": "kb-8842",
"chunk_seq": 3,
"section_path": "部署 > 回滚 > 手动回滚",
"embed_version": "text-embed-v3",
"chunk_version": "c4-800-120",
"ingested_at": "2026-10-02T03:11:20Z"
}
有了 embed_version 字段,一次全库扫描就能回答"还有多少条是旧向量":
-- 等价思路:按版本聚合计数,用来判断迁移是否完成
POST rag_docs/_search
{"size": 0, "aggs": {"by_ver": {"terms": {"field": "embed_version", "size": 10}}}}
迁移完成的标准不是"跑完了脚本",是旧版本计数为 0。
6. chunk 重叠比例与召回的关系
重叠是"用存储换完整性"的手段,但它有两个副作用容易被忽略:
| 重叠比例 | 好处 | 代价 | 常见误用 |
|---|---|---|---|
| 0 | 存储最省 | 边界语义被切断 | 表格、代码块断在中间 |
| 10%–15% | 边界句有覆盖 | 存储 +10~15% | 相对稳妥的默认 |
| 25%–30% | 强覆盖 | 冗余召回,去重成本上升 | 用于法律/合同类长段落 |
| > 40% | 边际收益极低 | 同一答案被召回 3 次,浪费上下文 | 误以为越多越好 |
def split_with_overlap(text, size=800, overlap=120):
if overlap >= size:
raise ValueError("overlap 必须小于 size")
step = size - overlap
return [text[i:i + size] for i in range(0, len(text), step)
if text[i:i + size].strip()]
重叠改变的是 chunk 内容,不是向量模型,所以它同样会让新旧数据不可比——改了重叠也要重灌。
7. 失效兜底:漂移期间还能答吗
漂移一定会有"发现到修复"的时间窗。这个窗口里需要兜底链路:
| 兜底 | 触发条件 | 行为 |
|---|---|---|
| 关键词召回 | 向量 top1 分数低于阈值 | 切 BM25,返回字面命中 |
| 无答案判定 | 最高分与次高分差距过小 | 明确说"没找到",不给推测 |
| 转人工 | 连续两次无答案 | 挂起会话 |
| 旧版索引 | 切换后指标恶化 | 别名回滚 |
def safe_retrieve(query, es, threshold=0.55, gap=0.03):
hits = vec_search(query, k=5)
if not hits or hits[0]["score"] < threshold:
return {"route": "bm25", "hits": bm25_search(query, k=5)}
if len(hits) > 1 and hits[0]["score"] - hits[1]["score"] < gap:
return {"route": "no_answer", "hits": []}
return {"route": "vector", "hits": hits}
阈值不跨库复用,必须用带上标注的评测集标定(见置信评分那篇)。
8. 排错:五种"越用越差"
| 现象 | 原因 | 处理 |
|---|---|---|
| 全量新文档答不了 | 只回灌了新文档,没回灌历史 | 按 embed_version 统计,补齐 |
| 老文档突然查不到 | 换了模型但索引没重建 | 双索引重建 + 别名切换 |
| 新领域问题最先变差 | 语料分布漂移,向量空间被拉偏 | 补该领域样本重训/微调,或加 BM25 兜底 |
| 答案残缺、总是少一句 | chunk 被切在句中 | 调分隔符优先级、加重叠 |
| 同一答案重复出现三次 | 重叠过大 | 重叠降到 10–15%,加去重 |
9. 边界
| 场景 | 做法 | 理由 |
|---|---|---|
| 文档量小、更新频繁 | 每次全量重建 | 比增量迁移简单可靠 |
| 多租户 | 按租户分索引 | 一个租户的漂移不污染其他 |
| 需要长期归档 | 归档库单独一套 embedding 版本 | 归档不必跟随升级 |
| 模型厂商下线旧版本 | 提前做双版本并行 | 保留一次回滚余地 |
| 查询日志里新词占比高 | 先补语料再看模型 | 多数漂移是语料问题 |
10. 自测
- 同模型 v1 与 v2 维度都是 768,为什么向量不可混用?
- 为什么要把 embedding 版本写进索引名,而不是只写进元数据字段?
- 双索引重建里,"切换别名"之前必须完成哪一步检查?
- 漂移监控中哪两个指标可以提前发现模型被替换?
- chunk 重叠从 10% 调到 40%,收益与代价分别是什么?
- 别名回滚后旧索引为什么不立刻删?