【Bug已解决】Bug: EnsembleRetriever silently overwrites metadata when documents share page_content

📅 发布时间:2026/8/17 18:21:29
【Bug已解决】Bug: EnsembleRetriever silently overwrites metadata when documents share page_content
【Bug已解决】Bug EnsembleRetriever silently overwrites metadata when documents share page_content一、现象长什么样EnsembleRetriever把多个底层 retriever比如 BM25 向量检索的结果按权重融合产出一份Document列表。当两个不同的 retriever 都召回了同一段page_content很常见同一篇文档既被关键词命中又被语义命中EnsembleRetriever在合并时会触发一个隐蔽 bug它把两条Document当同一条去重但合并 metadata 时用了简单的后者覆盖前者dest.metadata src.metadata或update整体替换导致其中一方的 metadata 被静默丢弃。比如向量检索带来{score: 0.9, source: vec}BM25 带来{score: 0.7, source: bm25}合并后只剩{score: 0.7, source: bm25}前者的score: 0.9没了且没有任何报错、没有任何日志。下游如果依赖metadata.score做重排或展示就会拿到错误的分数而排查时完全看不出 metadata 是怎么丢的——因为它静默发生了。二、背景Ensemble 融合的典型流程各自取 top-k → 按权重把分数写进metadata[score]→ 合并同 content 的文档 → 按 score 排序取最终 top-k。问题在于合并同 content 文档这一步。很多实现是seen {} for d in all_docs: if d.page_content in seen: # 错误直接用新文档的 metadata 覆盖 seen[d.page_content].metadata d.metadata else: seen[d.page_content] d或者用dict.update把新 metadata 整个盖上去。无论哪种当两条文档的 metadata键不同时就会有一方的键被整体替换/丢失而不是取并集。三、根因根因两点覆盖而非合并合并同 content 文档时metadata 用整体赋值/整体 update而非按键取并集。当两方 metadata 的键集合不同未被新值覆盖的键看似保留但实际赋值是整块替换旧键全没。静默无提示整个过程没有任何校验或日志metadata 丢失悄无声息调用方无从感知。本质去重合并把同 content 同文档粗暴等同于metadata 也相同但现实中同 content 来自不同 retriever 时metadata尤其是 score、来源恰恰是互补的应当合并而非覆盖。四、最小可运行复现下面缩略逻辑复现静默覆盖def bad_ensemble(docs): seen {} for d in docs: c d[page_content] if c in seen: seen[c][metadata] d[metadata] # 错误整块覆盖 else: seen[c] {page_content: c, metadata: dict(d[metadata])} return list(seen.values()) docs [ {page_content: X, metadata: {score: 0.9, source: vec}}, {page_content: X, metadata: {score: 0.7, source: bm25}}, ] out bad_ensemble(docs) print(out[0][metadata]) # {score: 0.7, source: bm25} —— vec 的 0.9 没了vec带来的score: 0.9被静默丢弃。五、解决方案第一层最小直接修复最小修法合并 metadata 时按键取并集同键用更高的 score 或加权值而非整块覆盖。def merge_meta(a, b): out dict(a) for k, v in b.items(): if k not in out: out[k] v elif k score: out[k] max(out[k], v) # 取较高分 else: out[k] v return out def safe_ensemble(docs): seen {} for d in docs: c d[page_content] if c in seen: seen[c][metadata] merge_meta(seen[c][metadata], d[metadata]) else: seen[c] {page_content: c, metadata: dict(d[metadata])} return list(seen.values())这一层保证不同 retriever 的 metadata 互补保留且 score 取最高。六、解决方案第二层结构化改进把Ensemble 合并策略固化成策略对象作为单一事实来源明确哪些键做 max、哪些做合并。from dataclasses import dataclass, field from typing import Dict, List dataclass(frozenTrue) class LangChainEnsembleMetaPolicy: EnsembleRetriever metadata 合并策略的单一事实来源。 score_key: str score score_agg: str max # max | sum | weighted union_keys: List[str] field(default_factorylambda: [source, retriever]) overwrite_on_conflict: bool False def merge(self, a: Dict, b: Dict) - Dict: out dict(a) for k, v in b.items(): if k self.score_key: if self.score_agg max: out[k] max(out.get(k, v), v) elif self.score_agg sum: out[k] out.get(k, 0) v elif k in self.union_keys: out.setdefault(k, v) # 保留首个来源不覆盖 else: out[k] v if self.overwrite_on_conflict: out.update({k: b[k] for k in b}) return out def validate(self) - None: if self.overwrite_on_conflict and self.union_keys: raise AssertionError(cannot overwrite and keep union keys)转换层用policy.merge替代整块覆盖互补语义集中可测。七、解决方案第三层断言 / CI 守护用 pytest 锁死合并语义import pytest from policy import LangChainEnsembleMetaPolicy as P def test_score_takes_max(): p P() out p.merge({score: 0.9, source: vec}, {score: 0.7, source: bm25}) assert out[score] 0.9 assert out[source] vec # 首个来源保留 def test_union_keys_not_overwritten(): p P() out p.merge({source: vec}, {source: bm25}) assert out[source] vec def test_conflict_policy_rejected(): with pytest.raises(AssertionError): P(overwrite_on_conflictTrue, union_keys[source]).validate() def test_no_silent_loss(): p P() docs [ {page_content: X, metadata: {score: 0.9, source: vec}}, {page_content: X, metadata: {score: 0.7, source: bm25}}, ] seen {} for d in docs: c d[page_content] seen[c] p.merge(seen[c], d[metadata]) if c in seen else dict(d[metadata]) assert seen[X][score] 0.9 # vec 的分数没有丢CI 加一条EnsembleRetriever单测必须覆盖同 content 不同 metadata用例断言 metadata 互补而非被覆盖。八、排查清单融合后metadata.score跟预期不符→ 可能被后者整块覆盖需按键合并。某一 retriever 带来的 metadata 键消失了→ 整块赋值把旧键全清了。是否静默发生无日志→ 合并处应加 debug 日志或断言。score 该取 max 还是 sum→ Ensemble 通常用 max/加权需明确。是否所有 metadata 键都相同→ 不同 retriever 的键常互补必须并集。与 MultiQueryRetriever 的合并是否共用策略→ 两者都应基于类型感知合并。九、小结EnsembleRetriever在合并同page_content文档时把 metadata 整块覆盖而非按键取并集导致某一 retriever 带来的 metadata尤其是 score、来源被静默丢弃下游重排拿到错误分数。第一层改为按 key 合并、score 取 max第二层用LangChainEnsembleMetaPolicy把合并语义固化成单一事实来源第三层用 pytest 守护 metadata 互补而非丢失。多 retriever 融合的通用原则同 content ≠ metadata 相同来自不同检索器的 metadata 通常是互补的合并必须取并集而非覆盖且丢失应有可观测性。