线上知识库换 Embedding 模型,真正麻烦的不是把一个模型名改成另一个。旧向量与新向量通常维度不同、分布不同,甚至文本预处理、截断长度和查询前缀也不同。查询若已经用新模型编码,却仍去搜索旧索引,轻则结果随机漂移,重则直接因维度不匹配报错;停机重建又会让更新期间写入的文档丢在新索引之外。
因此,Embedding 迁移本质上是一次数据系统迁移:既要复制历史状态,也要持续追赶在线变化,还要证明新系统可用,最后才能切流。本文用一个企业知识库的示例数据走完基线、双写、回填、校验、灰度、全量切换和回滚。所有数字都标明为示例或由本地脚本生成,不冒充生产实测;实现刻意使用标准库和抽象存储接口,便于替换成实际向量数据库。
一、先明确迁移对象:不只是一个浮点数组
一个可检索文档至少有四层身份:业务文档 ID、文档版本、切分后的 chunk ID、向量版本。很多事故来自把这四层混成一个主键。比如更新合同正文后沿用原 chunk ID,后台回填任务晚到一步,又把旧文本生成的新模型向量覆盖到目标索引;表面上目标集合数量齐全,内容却已经倒退。
更稳妥的做法是把原始文本库作为事实源,向量库只是可以重建的派生索引。每条索引记录至少携带 document_id、chunk_id、content_hash、source_version、embedding_version 和权限元数据。生成向量时用规范化后的实际输入计算 content_hash,而不是只对原始文件计算哈希。因为标题拼接、空白清理、语言前缀或最大长度变化,都会改变模型真正看到的字符串。
迁移前先冻结一份版本清单:旧模型标识、目标模型标识、向量维度、距离度量、文本预处理版本、切分版本、索引参数、查询编码参数。模型仓库里的名称不够,最好再记录制品摘要或服务发布版本。只写“迁移到新版中文模型”,故障时几乎无法复现。
#mermaid-svg-lZsyShwoxLfLTh5S{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lZsyShwoxLfLTh5S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lZsyShwoxLfLTh5S .error-icon{fill:#552222;}#mermaid-svg-lZsyShwoxLfLTh5S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lZsyShwoxLfLTh5S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lZsyShwoxLfLTh5S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lZsyShwoxLfLTh5S .marker.cross{stroke:#333333;}#mermaid-svg-lZsyShwoxLfLTh5S svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lZsyShwoxLfLTh5S p{margin:0;}#mermaid-svg-lZsyShwoxLfLTh5S .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-lZsyShwoxLfLTh5S .cluster-label text{fill:#333;}#mermaid-svg-lZsyShwoxLfLTh5S .cluster-label span{color:#333;}#mermaid-svg-lZsyShwoxLfLTh5S .cluster-label span p{background-color:transparent;}#mermaid-svg-lZsyShwoxLfLTh5S .label text,#mermaid-svg-lZsyShwoxLfLTh5S span{fill:#333;color:#333;}#mermaid-svg-lZsyShwoxLfLTh5S .node rect,#mermaid-svg-lZsyShwoxLfLTh5S .node circle,#mermaid-svg-lZsyShwoxLfLTh5S .node ellipse,#mermaid-svg-lZsyShwoxLfLTh5S .node polygon,#mermaid-svg-lZsyShwoxLfLTh5S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lZsyShwoxLfLTh5S .rough-node .label text,#mermaid-svg-lZsyShwoxLfLTh5S .node .label text,#mermaid-svg-lZsyShwoxLfLTh5S .image-shape .label,#mermaid-svg-lZsyShwoxLfLTh5S .icon-shape .label{text-anchor:middle;}#mermaid-svg-lZsyShwoxLfLTh5S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lZsyShwoxLfLTh5S .rough-node .label,#mermaid-svg-lZsyShwoxLfLTh5S .node .label,#mermaid-svg-lZsyShwoxLfLTh5S .image-shape .label,#mermaid-svg-lZsyShwoxLfLTh5S .icon-shape .label{text-align:center;}#mermaid-svg-lZsyShwoxLfLTh5S .node.clickable{cursor:pointer;}#mermaid-svg-lZsyShwoxLfLTh5S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lZsyShwoxLfLTh5S .arrowheadPath{fill:#333333;}#mermaid-svg-lZsyShwoxLfLTh5S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lZsyShwoxLfLTh5S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lZsyShwoxLfLTh5S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lZsyShwoxLfLTh5S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lZsyShwoxLfLTh5S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lZsyShwoxLfLTh5S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lZsyShwoxLfLTh5S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lZsyShwoxLfLTh5S .cluster text{fill:#333;}#mermaid-svg-lZsyShwoxLfLTh5S .cluster span{color:#333;}#mermaid-svg-lZsyShwoxLfLTh5S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lZsyShwoxLfLTh5S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lZsyShwoxLfLTh5S rect.text{fill:none;stroke-width:0;}#mermaid-svg-lZsyShwoxLfLTh5S .icon-shape,#mermaid-svg-lZsyShwoxLfLTh5S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lZsyShwoxLfLTh5S .icon-shape p,#mermaid-svg-lZsyShwoxLfLTh5S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lZsyShwoxLfLTh5S .icon-shape .label rect,#mermaid-svg-lZsyShwoxLfLTh5S .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lZsyShwoxLfLTh5S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lZsyShwoxLfLTh5S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lZsyShwoxLfLTh5S :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
原始文本事实源
旧模型编码
新模型编码
旧索引 Blue
新索引 Green
在线增删改
持久化变更日志
历史回填
影子对比与灰度路由
原子切换
观察窗口与可回滚保留
二、不要直接覆盖旧向量
最省事的错误方案,是遍历旧集合并原地更新向量。它有三个致命问题。第一,回填进行到一半时,同一集合中混有两个不可比较的向量空间;第二,查询服务无法知道某个点属于哪一代模型;第三,新模型效果不合格时,没有完整旧索引可以瞬间恢复。
生产迁移通常有两种形态。蓝绿集合适用于绝大多数数据库:保留旧集合,新建目标集合,查询别名最终从旧集合原子指向新集合。命名向量则在同一条 point 上同时保存旧、新向量,能复用 payload 和 ID,但依赖数据库能力,并且容量、索引构建与字段删除仍要谨慎。若团队对命名向量的运维经验不足,两个集合虽然占更多空间,却更容易理解和回滚。
无论哪种形态,都不能把“文档条数相等”当作完成。源集合可能含软删除、历史版本、无向量记录或多 chunk 文档;某些数据库给出的 count 还是近似值。验收应该从事实源出发,按确定条件计算应有的有效 chunk 集合,再对目标索引做集合差、哈希差和权限字段差。
三、迁移前先建立可比较的基线
如果旧系统没有基线,新模型上线后“感觉答案不一样”将无法归因。最小基线包含三类数据:完整性基线、检索质量基线、服务基线。完整性记录有效 chunk 数、按租户和文档类型分组的数量、抽样哈希;检索质量用固定题集保存 Top-k 与人工证据标注;服务基线记录请求成功率、P50/P95 延迟、模型编码错误率和空结果率。
题集不能只选热门简单问题。至少覆盖精确术语、别名、长问题、短问题、中文与英文混合、表格数值、权限过滤、无答案问题和近期更新文档。迁移只改变 Embedding 时,应尽量固定切分、reranker、Top-k 和生成模型,这样差异才主要来自向量空间。如果同时改五个变量,最终即使指标提升,也不知道哪个改动值得保留。
比较两套检索结果时,除了 Recall@k,还可以看 Top-k 集合重叠率。重叠率低并不自动表示新模型差,它只是提醒你深入检查。新模型可能找到了更好的同义表达,也可能把高风险答案推走。以下脚本对示例结果计算重叠率和命中情况,可直接保存为 compare_runs.py 运行。
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Case:
case_id: str
gold: frozenset[str]
old_topk: tuple[str, ...]
new_topk: tuple[str, ...]
def overlap_at_k(left: tuple[str, ...], right: tuple[str, ...], k: int) –> float:
a, b = set(left[:k]), set(right[:k])
return len(a & b) / max(1, len(a | b))
def hit(gold: frozenset[str], got: tuple[str, ...], k: int) –> bool:
return bool(gold & set(got[:k]))
def summarize(cases: list[Case], k: int = 3) –> dict[str, float]:
assert cases and k > 0
return {
"old_recall": sum(hit(c.gold, c.old_topk, k) for c in cases) / len(cases),
"new_recall": sum(hit(c.gold, c.new_topk, k) for c in cases) / len(cases),
"mean_overlap": sum(overlap_at_k(c.old_topk, c.new_topk, k) for c in cases) / len(cases),
}
def demo() –> None:
# 示例数据,只用于验证计算逻辑,不代表任何模型真实性能。
cases = [
Case("q1", frozenset({"c2"}), ("c1", "c2", "c3"), ("c2", "c4", "c1")),
Case("q2", frozenset({"c8"}), ("c7", "c9", "c6"), ("c8", "c7", "c5")),
]
result = summarize(cases)
assert result["old_recall"] == 0.5
assert result["new_recall"] == 1.0
assert 0.0 <= result["mean_overlap"] <= 1.0
print(result)
if __name__ == "__main__":
demo()
运行 python compare_runs.py 会打印由两条示例数据计算出的结果。真实评测时,把两套检索返回的稳定 chunk ID 填入同一题目,不要先让新模型多取二十条、旧模型只取五条再比较。若新旧模型需要不同相似度阈值,应先固定 Top-k 比排序质量,阈值校准另开实验。
四、双写的正确含义:先落事实,再异步建索引
“双写”经常被实现成请求线程依次调用旧库和新库:两个都成功才返回,否则整体报错。这样会把新模型的抖动直接传导到线上写入,也无法解决第一次成功、第二次超时但实际已落库的歧义。更可靠的最小设计是:业务事务先写原始文本和 outbox 事件,提交后由消费者分别更新两套索引。旧索引仍是当前服务目标,新索引失败只产生积压告警,不阻塞事实数据写入。
事件必须包含单调递增的业务版本或可比较更新时间。目标端更新采用“仅当事件版本不小于当前版本才覆盖”的条件写,删除也写成带版本的墓碑事件。否则回填扫描到旧版本时,可能在一次刚完成的在线更新之后到达,把新内容覆盖掉;删除事件若只对当下存在的点生效,回填稍后还会把已删除内容复活。
顺序的关键不在消息队列能否全局有序,而在同一个 chunk 的事件能否判新旧。全局有序成本高,也没有必要。以 (chunk_id, source_version) 做幂等键,以 source_version 或事实源提交序列做条件更新,消费者重试就不会制造重复版本。若源系统无法提供可靠版本,至少使用事务内生成的递增序列,而不是只用秒级时间戳。
下面是一个标准库实现的迁移状态机。它不连接具体数据库,只演示版本门禁、可重入回填和删除墓碑。内存字典是示例存储,生产中应换成带条件更新能力的持久化存储。
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
@dataclass(frozen=True)
class Event:
chunk_id: str
version: int
text: str | None
@dataclass(frozen=True)
class Indexed:
version: int
content_hash: str | None
deleted: bool
def apply_event(index: dict[str, Indexed], event: Event) –> bool:
current = index.get(event.chunk_id)
if current is not None and current.version > event.version:
return False
digest = None if event.text is None else sha256(event.text.encode("utf-8")).hexdigest()
index[event.chunk_id] = Indexed(event.version, digest, event.text is None)
return True
def demo() –> None:
target: dict[str, Indexed] = {}
assert apply_event(target, Event("c1", 2, "新正文"))
assert not apply_event(target, Event("c1", 1, "回填到达的旧正文"))
assert apply_event(target, Event("c1", 3, None))
assert target["c1"].deleted
assert not apply_event(target, Event("c1", 2, "迟到的回填"))
print(target)
if __name__ == "__main__":
demo()
运行 python migration_state.py,所有断言通过即可证明这段最小状态逻辑能挡住迟到旧事件和删除后复活。它没有证明消息队列、向量服务或数据库条件写正确,生产验收仍需在实际组件上做故障注入。
五、历史回填不是一次全表扫描
历史回填要能暂停、限速、重试和断点续跑。最安全的数据来源是原始文本事实库,而不是从旧向量反推文本。若旧索引 payload 恰好保存了完整输入,也应校验其内容哈希、版本和权限字段是否与事实源一致。Embedding 通常不可逆,仅有旧向量无法可靠恢复原文。
建议按稳定主键游标分页,而不是 OFFSET。大量并发更新时,偏移分页可能跳过或重复记录;稳定游标配合版本条件,即使重复读也安全。每批先读取一小组有效记录,批量调用新模型,再以条件写入目标集合,成功后提交游标。模型请求失败时不要跳过并推进游标;应重试有限次数,仍失败则记录死信及具体 chunk,待修复后补偿。
回填限速不能只看 CPU。它会占用模型并发、网络、目标库写吞吐、索引构建内存和磁盘 IOPS。把批大小、并发数和每秒向量数暴露成运行参数,观察在线查询 P95 与目标库资源后逐步提升。物理资源不足时,跑得慢是可接受的;把线上查询拖垮才是真事故。
进度也不能只报“已完成百分之八十”。更有用的看板包含:事实源应迁移数、目标有效数、待处理数、失败数、最近游标、事件追赶延迟、按租户分布、哈希不一致数和删除墓碑数。若目标数超过事实源,通常不是值得庆祝,而是有重复 ID、历史版本或删除复活。
六、完整性校验要做集合差,而不是抽三条
切流前至少做四层校验。第一层是结构:维度、距离度量、字段类型、分片、副本、权限索引均符合计划。第二层是集合:事实源有效 chunk ID 与目标有效 ID 做双向差集。第三层是内容:抽样或全量比较 content_hash、版本、租户和 ACL。第四层才是向量:向量存在、维度正确、数值有限、没有全零或异常范数。
不要比较新旧向量的余弦相似度来判断迁移正确。两个模型的坐标系没有对齐,相同文本的跨模型向量通常不可直接比较。正确验证方式是重新用目标模型编码同一规范化文本,确认目标记录的向量来自相同输入和相同版本;更实用的是运行固定检索题集并检查证据命中。
权限字段是最容易被忽略的一层。新集合若少了租户过滤或部门 ACL,离线 Recall 可能更高,因为它检索到了本不该看到的文档。质量验收必须在真实过滤条件下运行,并加入“用户 A 无权查看用户 B 文档”的负向样例。迁移成功的底线先是没有越权,再是相关性提升。
七、影子流量:比较结果,不影响用户
完成回填和事件追平后,不要立刻把用户请求切到新索引。先在查询层复制一小部分请求:旧索引结果照常服务用户,新索引仅异步计算并记录差异。影子请求要复用相同的规范化查询、权限过滤、Top-k 和超时预算,但不能重复触发计费工具或写操作;对于纯检索调用,影子方式最合适。
记录原始用户问题前要先做隐私评估。生产问题可能含姓名、账号、合同或故障详情。可以记录不可逆查询哈希、题型、两套 chunk ID、排名和耗时;需要人工复核正文时,只对授权样本保留最小必要片段并设置保留期。把所有问题原文无期限写进“迁移日志”,不是可观测性,而是新的数据风险。
影子比较关注三类异常:新索引超时或报错;高风险题的金标准证据消失;结果发生大幅漂移且无法解释。对于没有金标准的真实流量,可统计 Top-k 重叠、首条文档变化、跨版本命中和空结果变化,再抽样人工判定。不要用点击率在短期内代替相关性,因为位置偏差、用户任务变化和展示改版都会污染点击信号。
八、灰度路由必须以请求为单位固定版本
灰度阶段可先让内部账号和测试租户使用新索引,再按稳定哈希扩大到百分之一、百分之五、百分之二十。路由键应选择用户或会话,使同一会话不会一会儿搜旧索引、一会儿搜新索引。随机按请求分流虽然容易,却会让多轮问答结果抖动,也使用户反馈难以复现。
更重要的是,查询编码模型和索引版本必须作为一个不可拆分的配置单元。一次请求开始时读取配置快照,后续编码、查询、日志都携带同一 embedding_version。不能先读取“新模型”,几毫秒后别名又指回旧集合。原子别名只能保证集合指向切换,应用层仍要保证模型配置与集合别名同步。
灰度门禁不应只有平均 Recall。至少包括:高风险题不退化、权限负例全部通过、空结果率不显著恶化、新模型错误率低于阈值、P95 延迟在预算内、事件延迟接近零、目标集合无缺口。阈值应在迁移计划中预先写明,而不是看到数据后临时挑一个有利解释。
九、切流不是终点:先保留双写和旧索引
当灰度达到全量后,先切查询,暂时继续双写。这样如果几小时后发现长尾问题,旧索引仍追得上最新事实,可以快速回滚。观察窗口多长取决于业务周期:高频客服可能一天覆盖大部分问题,月度报表知识库则需要更长时间。不能照抄固定七天,而要看关键查询是否真正出现过。
回滚操作应在迁移前演练,且最好只需要切换一份版本化配置或原子别名。回滚时同时恢复旧查询模型与旧索引,不能只改集合名。若新模型上线期间产生了新文档,而旧索引的双写失败或已停用,回滚会丢失这部分检索能力,所以停止双写必须晚于回滚窗口结束。
真正准备下线旧索引前,做一次备份或快照,记录恢复命令和校验结果,再关闭旧模型编码消费,最后删除资源。顺序不要反过来。对大集合而言,保留一个可恢复快照通常比长期保留双倍在线资源便宜;但快照是否能跨版本、跨集群恢复,需要按所用数据库的官方说明实际演练。
十、一次真实可执行的发布清单
准备阶段:确认事实源可重放;固定旧、新模型和预处理版本;估算目标磁盘、内存与编码配额;建立稳定 chunk ID;冻结题集和旧基线;写清切流及回滚负责人。不要在没有原始文本时开始迁移,因为任何后续重建都会受制于旧索引残缺 payload。
构建阶段:创建目标集合;接通 outbox 消费;先验证少量双写;启动可续跑回填;监控失败与事件延迟;处理删除和更新竞态。回填完成后,再从事实源做 ID、版本、哈希和 ACL 差异校验,而不是相信任务日志里的“成功”。
验证阶段:跑固定检索集;检查高风险切片;启动脱敏影子流量;抽样分析大幅漂移;做目标库故障和新模型超时演练;确认错误会降级到旧索引或安全失败。若应用采用回退查询,日志必须明确本次实际用了哪一代索引,否则指标会把回退成功算成新系统成功。
发布阶段:固定用户灰度;逐级扩大;每一级等待足够样本;原子切换查询;保留双写和旧索引;完成观察期后停止旧写;制作可恢复快照;最后释放旧资源。任何阶段触发门禁都应停止扩大,而不是为了赶窗口降低阈值。
十一、常见失败以及真正的根因
新集合数量少几个百分点。 常见根因不是回填“还差一点”,而是解析失败被吞掉、租户过滤错误、超长文本被模型拒绝、消费者推进游标后才发现批次部分失败。需要按失败类别列出具体 ID,并让游标提交与批次成功语义匹配。
新模型离线分更高,线上却更差。 可能是题集词面偏向新模型、查询前缀线上漏配、实际 Top-k 不同、权限过滤后候选不足,或新编码服务延迟导致大量请求回退。应把请求级模型版本、索引版本、过滤条件和是否回退串在同一 Trace 中。
灰度期间出现旧内容复活。 这是删除与回填竞态。删除没有持久化墓碑,或目标端条件写只比较到达时间而不比较业务版本。解决点在版本化事件和条件更新,而不是回填结束后再人工删一次。
回滚后新文档搜不到。 通常是切流后过早停止旧索引双写,或旧消费者积压没有告警。回滚能力不能只证明“别名能切回”,还要证明旧索引的数据新鲜度在约定范围内。
成本突然翻倍。 双写与回填本来就会短期增加编码量,但失控通常来自重复扫描、无幂等缓存、失败批次整体重算或影子流量比例过高。以 embedding_version + content_hash 作为可复用键,文本未变化时无需重复编码;但要确保不同预处理版本不会错误共用缓存。
十二、容量预算与限速:回填任务不能靠“慢慢跑”管理
开始回填前,先用事实源抽样估算总工作量。假设有效 chunk 数为 N,目标模型每批实际吞吐为 R,理论编码时间是 N/R;但这只是下限,还要加上读取、网络、条件写、索引优化和失败重试。更关键的是算临时资源峰值:蓝绿集合会同时保留两份 payload 和向量,目标索引构建还可能临时占用额外磁盘与内存。容量不够时,回填跑到九成才失败,比一开始慢一些更难恢复。
不要用模型厂商标称吞吐直接排期。先用不含敏感数据的代表性样本压测,覆盖短文本、接近最大长度的文本、中文英文混合和异常字符,记录每批成功数、令牌或字符量、P95 延迟与限流比例。批量接口通常受“单批条数”和“总输入量”双重限制;只按条数设批次,遇到长文档就会随机超限。生产回填应同时限制条数和总字符预算。
限速器需要接收在线服务反馈。最简单的策略是为回填分配独立并发配额,并设置可随时调整的最大请求数;当在线编码 P95、目标库写延迟或错误率超过门槛,就自动降低后台并发,而不是等值班人员手工停任务。限速参数、暂停原因和恢复时间写入运行记录,否则第二天看到吞吐下降时无法分辨是系统变慢还是保护策略生效。
回填缓存应以规范化输入哈希和目标版本为键。相同文本在同一模型、同一预处理规则下可复用向量,但不同租户即使文本相同,权限 payload 仍需分别写入。不要把“向量可复用”误解成“索引记录可合并”,否则一个租户删除公共模板时可能影响另一个租户。缓存还要有失败状态和有限重试,不能把超时产生的空向量当成功永久命中。
十三、双索引一致性对账:用事实源裁决谁对谁错
双写运行一段时间后,新旧集合之间出现差异是正常现象,但每个差异都必须能解释。对账不要直接把旧集合当金标准,因为旧集合本身可能已有脏数据。先从事实源生成当前应存在的 (chunk_id, source_version, content_hash, acl_hash) 清单,再分别读取两套索引的元数据。由此可得到源缺失、目标缺失、版本落后、哈希不符、ACL 不符和额外孤儿六类集合。
对账任务应使用一致的时间水位。若扫描事实源花了十分钟,同时在线更新仍在发生,拿扫描起点的源状态与扫描终点的索引状态比较,会制造大量假差异。可读取数据库快照或记录提交序列上限,只核对不晚于水位的版本;无法获得一致快照时,先记录差异,等待超过消息最大延迟后复查,连续两轮存在才进入修复队列。
修复也必须走与在线更新相同的版本门禁。看到目标缺一条便无条件 upsert,可能覆盖刚写入的新版本。正确做法是回到事实源读取当前版本,重新生成目标向量,并以“目标版本小于当前事实版本”为条件写入。孤儿记录则先确认对应删除事件与保留政策,再写墓碑或删除;不要让对账脚本获得无条件批量删除权限。
每次切流前生成一份不可变对账报告,至少包含水位、应有数量、六类差异数量、未解决 ID、抽样方式和脚本版本。若团队决定接受某类差异,例如已知无法解析的加密附件,要明确列为豁免并标负责人和失效日期,而不是从统计中静默过滤。只有这样,下一次迁移才能判断问题是历史遗留还是新引入。
十四、灰度故障与回滚演练:把最坏窗口提前走一遍
正式灰度前,在隔离环境或低风险租户至少演练四种故障。第一,新编码服务整体超时,验证在线新流量会回退旧系统或安全失败,后台回填不会无限并发重试。第二,目标向量库只读或磁盘接近满,验证事实写入仍可提交、outbox 保留且告警能够指出目标积压。第三,别名已经切换但应用配置仍使用旧查询模型,验证维度或版本门禁会阻断,而不是返回不可解释结果。第四,回滚后继续创建和删除文档,验证旧索引仍保持新鲜。
演练回滚不要止于“执行别名切换命令成功”。先在新索引写入一条仅灰度期间出现的示例文档,确认旧端双写已经收到;切回后用旧模型查询并检查稳定 chunk ID。再删除该文档,模拟删除事件迟到与回填重放,确认墓碑版本能够阻止复活。最后比较回滚前后权限负例,避免恢复旧数据时恢复了过期 ACL。
若路由配置通过缓存分发,需要测量全量实例收敛时间。控制面已显示切回,不代表每个 API 实例都停止访问新集合。请求日志应携带路由配置版本和实际索引版本,回滚完成条件是所有活跃实例均报告旧版本,且新集合查询量降到预期影子比例。对无法热更新的进程,使用滚动重启也要防止新旧配置长期混跑。
回滚之后不要立即删除目标集合。先冻结其写入,保存故障时的索引状态、差异报告和请求样本,方便复盘。如果根因只是查询前缀漏配,修复后可以从事实水位继续追赶,而不必重跑全部历史;如果发现向量生成输入错误,则应废弃目标向量并从事实源重建。保留证据不等于继续向用户提供故障结果,两者可以分离。
十五、安全、合规与适用边界
若文本含个人信息、源代码、合同或医疗资料,新模型服务的位置本身就是数据出境与供应链决策。迁移前应确认处理区域、日志策略、保留期限和供应商协议。不要因为它只是“生成向量”就认为原文没有离开系统;模型服务仍然接收了完整输入。
向量和 payload 也不是无敏感数据。成员推断、近邻泄露和错误权限过滤都可能暴露信息。目标集合必须继承原 ACL,并用未授权查询做负向回归。迁移脚本的服务账号只授予所需集合与事实源的最小权限,凭据放在环境或密钥管理系统中,不写进脚本、镜像和文章示例。
本文方案适合有持续写入、不能长时间停机、且保留可重建原文的检索系统。若数据只有几千条、允许维护窗口,停写后离线重建并一次切换可能更简单可靠,不必为“双写平台”增加永久组件。若数据库已原生支持命名向量、条件更新和原子别名,应优先使用;没有这些能力时,也可以用两个集合加应用路由完成最小闭环。
Embedding 迁移没有真正意义上的“零风险”,只有把风险拆开并留下证据。最值得坚持的原则是:旧系统保持可用,历史数据可重复回填,在线变化不会丢,切换有门禁,回滚时旧数据仍然新鲜。做到这五点,更换模型才是一次受控发布,而不是在生产向量空间里豪赌。
参考资料
- Qdrant:Migrate to a New Embedding Model
- Qdrant:Collections 与原子 Alias
- Qdrant:Migration Guidance
- Sentence Transformers:Semantic Search
网硕互联帮助中心

评论前必须登录!
注册