向量检索静默失效:存量向量与查询向量不在同一空间¶
2026 年 7 月,goagent 知识库第一次做分模式对照评测时发现:向量召回从入库起就没有正常工作,但端到端回答一直看起来正常。
暴露过程¶
那次评测把检索拆成纯词法、纯向量、混合三种模式跑同一题集(34 题)。第一轮结果:纯向量模式 recall@5 只有 5.9%,纯词法 47.1%。这个差距用融合权重解释不了:向量路权重偏低时仍会贡献一部分前排候选,recall 不会掉到个位数。调权重救不了这个差距,第一步是先看向量路自己的原始输出。
生产上没有任何报警。向量路返回噪声时,RRF 按各路排名打分,噪声候选在向量路自己的排序里就靠不住,融合后占不到前排,结果由词法路主导;rerank 只对已有的候选重排,救不回向量路丢掉的相关文档。端到端看不出异常,单看生产指标也发现不了,只有把召回路径拆开单独评测,失效才会暴露。
定位¶
排查按"输出是不是噪声 → 噪声来自检索还是向量本身 → 当前服务有没有问题"的顺序走,每一步用一个对照实验收口。
先看向量检索的原始结果:一个物理问题的向量近邻全是不相关主题的文档,top-1 余弦相似度 0.36,整个近邻列表挤在 0.34 到 0.36 的窄带里。关键在于整列只分布在 0.02 宽的带子里:近邻检索有意义的前提是相关对明显高于不相关对,top-1 到末尾只差零点零几,排序基本没有依据,这份近邻列表就是噪声。
接着定位噪声的来源。取一段含"欧姆定律"的正文,用当前嵌入配置重新生成向量,与它自己当初存入的向量算余弦,结果是 -0.0088。特意选同一段文本做对照,是为了排除文本差异的干扰:同一模型对同一文本重嵌,结果应当几乎相同;几乎正交说明两次嵌入不在同一个向量空间。再取物理段落与不相关段落的存量向量互算,同样全部落在 0 附近。1024 维随机向量两两余弦的期望本来就是 0±0.03,存量向量在统计上与随机噪声不可区分。到这一步可以排除"个别文档损坏":整批存量向量都不是当前模型产生的。
然后排除在线服务:用当前嵌入服务生成查询向量,与相关内容相似度 0.79,与无关内容 0.30,区分度正常。当前查询侧健康,问题只剩历史入库侧一个解释。
结论:历史入库时用的嵌入源和当前配置不兼容,可能是换过模型,也可能是当时的代理返回值损坏,源记录里没有进一步区分这两种可能。全程没有报错的原因在于:向量索引和距离计算只校验维度,不记录向量来自哪个模型。维度匹配,检索在数学上正常执行,返回的近邻在语义上没有意义,系统不给出任何显式信号。入库和检索两侧从未校验向量空间一致性,换嵌入供应商、换模型、供应商内部升级模型,都会让存量向量静默失效。
修复与验证¶
回填。关键约束是重新嵌入用的文本构造必须与入库管线完全一致:同一个模型对略有差异的输入也会产生不同的向量,如果回填自己拼文本,回填结果又会和后续增量摄取对不上空间。回填工具直接复用摄取管线的文本构造函数:
internal/knowledge/embedding_backfill.go # Service.ReembedAllChunks:逐批嵌入、逐批落库
cmd/agent-core-reembed # 一次性回填入口
compiledChunkEmbeddingText # 与摄取管线共用的构造:section 路径 + 摘要 + 正文
UpdateKnowledgeChunkEmbeddings # store 层新增的批量更新(pgx.Batch)
逐批落库是为了中断后重跑幂等;单批三次重试抗瞬时网络抖动;失败的 chunk 计数上报,不静默跳过——这类失效最难被发现,不能再静默丢数据。本次回填 1032 个文档、12014 个 chunk,约 32 分钟,零失败。
回填后复跑同一题集:向量模式 recall@5 从 0.059 恢复到 0.412,MRR@10 从 0.059 到 0.412;词法模式不受影响(0.471,符合预期,它不依赖向量);混合加重排仍是最优组合(recall@5 0.471,rerank 阶段把 MRR 从 0.397 提到 0.451)。探针同步验证:物理问题的向量近邻变回相关文档,top-1 相似度 0.80。
遗留¶
入库与检索路径仍没有向量空间一致性的自动校验。这类故障没有任何一路会报错,只能靠对照实验暴露。可行方向:在文档表记录嵌入模型标识、检索时比对,或定期抽样做自相似度探针(重嵌少量 chunk 与存量向量比对),低于阈值就告警。落地之前,兜底手段是把分模式评测保留在可以随时执行的路径上,向量空间再次漂移时 recall 会直接掉下来。
同一项目后来还出过一次独立的向量静默失效,见向量查询在事务里静默失败。两次形态相同:向量路失效,词法路返回正常结果,没有报错。