【Bug已解决】Bug MultiQueryRetriever.unique_union() crashes on documents with listdict metadata一、现象长什么样这一篇聚焦MultiQueryRetriever.unique_union()在具体文档带 list/dict metadata 时的崩溃和通用 metadata 合并角度不同这里强调文档层面——每个Document对象自带嵌套 metadata当多个变体 query 召回同一篇文档、且该文档 metadata 含list如多标签或dict如来源对象时unique_union在把多条去重成一条时会崩溃。报错通常是TypeError: unhashable type: list或构建集合去重 metadata 键时TypeError: argument of type dict is not iterable更隐蔽的一种它不崩但把 list 直接当标量update覆盖导致被哪些 query 命中的标签信息只剩下最后一个 query 的丢失了多 query 召回的语义价值。二、背景MultiQuery 的典型用法一个问题 → LLM 生成 N 个改写 query → 每个 query 各自retriever.get_relevant_documents→ 把 N 批结果unique_union合并。合并时按page_content去重把同一文档的多条记录合成一条并试图合并 metadata以记录它来自哪些 query。但真实文档的 metadata 往往不是扁平标量tags是 listsource_info是 dictchunk_path可能是 list of str。当unique_union对这些值做集合去重或update 覆盖时就触发上述崩溃或静默丢失。本篇与 845 的区别845 站在metadata 合并函数视角讲类型分支本篇站在Document 对象去重视角强调多 query 召回同一文档时的 list/dict 处理并给出面向Document的修复。三、根因根因在unique_union的 Document 级去重对 Document.metadata 做set()去重想把多条记录的 metadata 键合并唯一但值里有 list/dict构造集合时因不可哈希崩溃。metadata.update(other.metadata)覆盖把后一条的 metadata 整块盖到前一条list 值被整体替换丢失多 query 命中的累积信息比如queries_hit: [q1,q2]变成只有[q2]。未区分文档自带的嵌套 metadata与union 过程产生的聚合字段把两者混在一起处理越搞越乱。本质去重逻辑把 Document 的 metadata 当成可自由集合化的扁平字典忽略了真实文档 metadata 的嵌套性与聚合字段应累积的语义。四、最小可运行复现下面缩略逻辑复现文档级崩溃def bad_unique_union_docs(docs): by_content {} for d in docs: c d.page_content if c not in by_content: by_content[c] d else: # 想合并 metadata 的键集合 keys set(by_content[c].metadata) | set(d.metadata) # 若值是 list/dict 这里不直接崩 merged {} for k in keys: a by_content[c].metadata.get(k) b d.metadata.get(k) # 对 list 值做 set 去重 - 崩溃 merged[k] list(set(a) | set(b)) by_content[c] type(d)(page_contentc, metadatamerged) return list(by_content.values()) docs [ type(D, (), {page_content: X, metadata: {tags: [a]}})(), type(D, (), {page_content: X, metadata: {tags: [b]}})(), ] bad_unique_union_docs(docs) # TypeError: unhashable type: list五、解决方案第一层最小直接修复最小修法写一个 Document 感知的合并对 list 值拼接去重、dict 值深合并、标量覆盖且为 union 过程产生的聚合字段如命中的 query 列表做累积。def merge_doc_meta(a: dict, b: dict) - dict: out dict(a) for k, v in b.items(): if k not in out: out[k] v elif isinstance(out[k], list) and isinstance(v, list): out[k] out[k] [x for x in v if x not in out[k]] elif isinstance(out[k], dict) and isinstance(v, dict): out[k] {**out[k], **v} else: out[k] v return out def safe_unique_union_docs(docs): by_content {} for d in docs: c d.page_content if c not in by_content: by_content[c] d else: merged merge_doc_meta(by_content[c].metadata, d.metadata) by_content[c] type(d)(page_contentc, metadatamerged) return list(by_content.values())这一层让带嵌套 metadata 的文档去重不再崩溃且 list 标签被累积。六、解决方案第二层结构化改进把Document 级 metadata 合并固化成策略对象作为单一事实来源并显式区分文档自带字段与聚合字段。from dataclasses import dataclass, field from typing import Any, Dict, List dataclass(frozenTrue) class LangChainMultiQueryMetaCrashPolicy: MultiQuery Document 去重合并策略的单一事实来源。 list_merge: str concat_dedup dict_merge: str deep aggregate_keys: List[str] field(default_factorylambda: [queries_hit]) aggregate_mode: str accumulate # accumulate | overwrite def merge_value(self, a: Any, b: Any) - Any: if isinstance(a, list) and isinstance(b, list): return a [x for x in b if x not in a] if self.list_merge concat_dedup else b if isinstance(a, dict) and isinstance(b, dict): return {**a, **b} if self.dict_merge deep else b return b def merge(self, a: Dict, b: Dict) - Dict: out dict(a) for k, v in b.items(): if k in self.aggregate_keys and self.aggregate_mode accumulate: out[k] (out.get(k, []) [v]) if not isinstance(out.get(k), list) \ else out[k] [x for x in (v if isinstance(v, list) else [v]) if x not in out[k]] elif k in out: out[k] self.merge_value(out[k], v) else: out[k] v return out def validate(self) - None: if self.aggregate_mode not in (accumulate, overwrite): raise AssertionError(bad aggregate_mode)这样unique_union对每个 Document 调用policy.merge嵌套结构与聚合字段都有确定语义。七、解决方案第三层断言 / CI 守护用 pytest 锁死 Document 级合并import pytest from policy import LangChainMultiQueryMetaCrashPolicy as P class Doc: def __init__(self, page_content, metadata): self.page_content page_content self.metadata metadata def test_nested_no_crash(): p P() docs [ Doc(X, {tags: [a], src: {f: 1}}), Doc(X, {tags: [b], src: {g: 2}}), ] by {} for d in docs: by[d.page_content] d if d.page_content not in by \ else Doc(d.page_content, p.merge(by[d.page_content].metadata, d.metadata)) m by[X].metadata assert m[tags] [a, b] assert m[src] {f: 1, g: 2} def test_aggregate_queries_hit(): p P() a {queries_hit: [q1]} b p.merge(a, {queries_hit: [q2]}) assert b[queries_hit] [q1, q2] def test_policy_valid(): P().validate()CI 加一条MultiQueryRetriever单测必须构造带 list/dict metadata 的真实 Document走unique_union断言不抛TypeError。八、排查清单unique_union在真实文档上崩unhashable type→ metadata 值被set()需类型分支。多 query 命中信息只剩最后一个→ list 值被update覆盖应累积。是否区分文档自带 metadata与聚合字段→ 聚合字段如 queries_hit应 accumulate。dict metadata 合并是否符合预期→ 深合并而非覆盖。是否只对 metadata 键做集合→ 值也可能嵌套不能假设扁平。与 845 的关系→ 本文档级视角845 是函数级视角可共用同一策略类。九、小结MultiQueryRetriever.unique_union()在处理带 list/dict metadata 的真实 Document时因对 metadata 值做集合去重或整块覆盖而崩溃/静默丢失。与 845 的合并函数类型分支互补本篇从 Document 去重视角给出修复对 list 拼接去重、dict 深合并并为多 query 命中这类聚合字段做累积。策略固化于LangChainMultiQueryMetaCrashPolicy并用 pytest CI 守护。多 query 召回合并的通用原则同一文档被多个 query 命中时metadata 应当累积而非覆盖且嵌套结构必须类型感知。