2535 字
约 8 分钟
16
S20-X05 · 语义漂移:向量检索为什么越用越差

"同一套流程,上个月还准,现在明显不行了"——这类抱怨的根因几乎从不是模型变笨,而是索引里的向量和查询用的向量不再是同一个坐标系。向量检索对坐标系极其敏感:文本看起来没变,向量却可能整体偏移,于是最近邻全错。

漂移是四种来源的叠加,且每一种都能单独把 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. 自测

  1. 同模型 v1 与 v2 维度都是 768,为什么向量不可混用?
  2. 为什么要把 embedding 版本写进索引名,而不是只写进元数据字段?
  3. 双索引重建里,"切换别名"之前必须完成哪一步检查?
  4. 漂移监控中哪两个指标可以提前发现模型被替换?
  5. chunk 重叠从 10% 调到 40%,收益与代价分别是什么?
  6. 别名回滚后旧索引为什么不立刻删?
S20-X05 · 语义漂移:向量检索为什么越用越差
https://www.clxhxhhr.top/posts/5680/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。