主题
第 4 章 写入:抽取、分型与冲突消解
2026 年 3 月,一家订阅制 SaaS 公司的客服团队发现他们的 Agent 会在电话里坚持说客户「没有订阅」。查下去,库里躺着两条记忆:一条是 2025 年 11 月的「客户已退订」,一条是 2026 年 2 月的「客户已重新订阅」。两条都在,两条都是真的,召回把旧的那条排在了前面。
这不是检索的缺陷,是写入时没有回答一个问题:新事实到来时,旧事实怎么办。这个问题没有中立答案,因为它同时受三个约束——事实不能丢、成本不能失控、用户必须能推翻。三个约束互相拉扯,任何方案都只能在某个方向上让步。
常见的让步有三种。第一种,让模型在写入时顺便决定是更新还是删除,代价是一次写入要两次模型调用,而且一次糟糕的更新会静默抹掉一条真事实,失败没有声音。第二种,什么都不做,只追加,代价是库随会话长度单调膨胀,互相矛盾的条目永久共存。第三种,把判断推迟到读取时,代价是每次召回都要重做一遍消解,延迟随库增长。
这三种让步在 2026 年的主流实现里都有对应物,而且它们的差异不在「谁更先进」,在于各自把成本放在了哪一段路径上。把成本放在写入段,会拖慢每一次对话;放在存储段,会让备份与迁移变重;放在读取段,会让延迟随库规模漂移。选哪一种,取决于这个系统更怕哪一类失效。
本章按写入的真实执行顺序展开。4.1 拆开一次写入在工程上究竟发生了几件事;4.2 说明时间为什么必须有两个轴,以及少一个轴会答错哪类问题;4.3 讲分型这套枚举在解决什么、又给提取器带来什么负担;4.4 讲冲突怎么判、判完怎么动手;4.5 与 4.6 分别处理两类最容易被忽略的输入——图像与中文。
4.1 单遍写入的工程逻辑
4.1.1 一次调用里发生了什么
把 add() 当成「往库里塞一段文本」是接入阶段最常见的误解。它实际上是一次流水线:一次模型调用产出一批事实,然后每条事实各自穿过四个阶段。这五个阶段的分工必须清楚,因为它们失败的后果完全不同——有的失败会丢数据,有的失败只会丢索引。
第一,抽取。 输入可以是裸字符串、单条 message,也可以是 message 列表。抽取器把它切成若干条独立事实,每条带类别、置信度与实体。这一步的质量决定了后面所有环节的上限:抽取器没看见的事实,检索再强也召不回来。
第二,安全公证(notary)。 在数据落盘之前执行,做三件事:凭据检测并按字段脱敏、不可见 Unicode 检测(零宽字符、双向控制符)、HTML 注入检测。脱敏发生在实体抽取与嵌入之前,所以掩码后的值不会进入索引;而注入检测使用未脱敏的原始文本,因为脱敏之后凭据模式已经不存在,告警能力会一起消失。
第三,落库与建索引。 SQLite 主表保存结构化字段,FTS5 虚表保存分词后的全文用于关键词检索,向量后端保存嵌入。这里有一个容易被做错的细节:向量必须只算一次,同时喂给 SQLite 记录与向量后端。只写后端而把记录的 embedding 留空,会让语义通道在候选打分时按零分处理,等于白召回。
第四,追加哈希链。 每个写动作在哈希链审计账本上追加一条 SHA-256 链条目。它是链而不是日志,因为链能定位到具体被改的那一条,日志只能证明「有变化」。
第五,持久化操作日志。 异步模式下,add() 立刻返回 {"event_id", "status": "PENDING"},调用方用事件号轮询。日志必须持久化而不是放内存,否则进程重启后一次已被接受的写入会变成「查无此事」。
五个阶段的失败后果差别很大,接入方按这张表决定哪些环节值得重试。
| 阶段 | 失败表现 | 后果 | 是否可重试 |
|---|---|---|---|
| 抽取 | 返回空事实列表 | 这一轮什么都没记住,无报错 | 可,但需换提取器 |
| 公证 | 脱敏漏项 | 凭据以明文进入索引与嵌入 | 不可,须先清库 |
| 落库 | 主表成功、向量为空 | 该条只能被关键词召回 | 可,补算嵌入 |
| 哈希链 | 追加失败 | 写入有效但不可审计 | 可,账本可补写 |
| 操作日志 | 状态停在 RUNNING | 调用方无法判断成败 | 需人工核对 |
表 4-1 写入管线各阶段的失败后果
表里第二行是最容易被低估的。其余四行都可以事后补救,唯独凭据一旦以明文进入嵌入向量,补救办法只有清库——因为向量里已经含有那个值的语义表示,改文本不会把它从向量里拿掉。
下面这段代码验证最小写入路径:不配置任何模型,只用内置规则提取器与内置嵌入器。
python
from mnemosyne import Memory
m = Memory() # 内置嵌入器 + 规则提取器
out = m.add("I moved to Berlin in 2023.",
user_id="alice", observation_date="2023-06-01")
for r in out["results"]:
print(r["event"], r["categories"], r["memory"])返回的每条结果都带 event 字段,单遍写入下它的取值恒为 ADD(完全重复的内容会被前置去重拦下,标记为 DUPLICATE 且不产生新 id)。observation_date 不参与抽取,它只影响时间字段,下一节展开。
add() 的参数面比最小示例宽得多,其中三个参数直接影响写入语义,值得单独说明:infer=False 跳过抽取、按轮次逐字入库,适合把转录文本原样保存;immutable=True 标记该条不可改写,用于发票号、合同编号这类不该被后续更新覆盖的值;includes / excludes 在抽取之后做一次词面过滤,用来把某类事实挡在库外。三者都是「在单遍流水线上加一个旁路」,而不是换一条管线。
4.1.2 为什么不做第二次调用
第二次调用的动机是合理的:模型看到库里已有的记忆,才能判断该覆盖还是该追加。但这个设计有三个代价,第三个是致命的。
第一是成本结构。判断需要把已有记忆放进提示,一次写入的提示体积变成原来的三倍量级,延迟翻倍。对于每个回合都写一次的对话型 Agent,这笔账在长会话里会失控。
第二是失败模式的形状。追加是单调的,一次糟糕的抽取只能引入噪声;而一次糟糕的更新会静默抹掉一条真事实,且事后无法区分「这条从来没被记住」与「这条被后来的更新删了」。噪声可以靠排序和阈值压住,丢失无法。
第三是它把「两条说法不一致」这个语义问题降级成了「一次模型判断」。而不一致本身是有信息量的——它可能意味着用户改主意了,也可能意味着其中一条记错了。单遍写入把矛盾保留下来,交给 4.4 的冲突消解去分类处理,这比让提取器在一次调用里顺手选边要可审计得多。
代价必须说清楚:只增不改意味着存储单调增长。写入通道永远开放,读取成本却随库增长,两者之间的差额只能靠分层降级与无损压缩去还(第 5 章)。同时,单遍提取的召回上限被提取器质量锁死:如果提取器只读用户回合、不读助手回合,那么「我已经把文件保存到 /tmp/x」这类由 Agent 自己确认的动作永远进不了库,后续轮次就无从知道这件事发生过。
这就引出配置判据。
| 判据 | 内置规则提取器够用 | 需要配置 LLM 提取器 |
|---|---|---|
| 输入语言 | 单一语言、句式规整 | 多语言混写、长句嵌套 |
| 事实密度 | 每轮 0–2 条事实 | 一段话含 5 条以上独立事实 |
| 领域词表 | 通用词(偏好、住址、职位) | 领域术语、型号、编号 |
| 失败代价 | 漏一条只是少一条记忆 | 漏一条导致下游判断错 |
| 成本约束 | 无 API key、必须离线 | 可接受每写一次一次调用 |
表 4-2 提取器选型的判据
规则提取器不是「降级方案」,它是一条独立可用的路径:确定性、零延迟、零费用,且在没有网络的环境里是唯一选择。它的边界在于泛化——它靠模式匹配,遇到「我上个月把工位搬到 17 楼,原来的 3 楼留给实习生」这种一句话两个事实、还夹带相对时间的输入,容易只抽到一半。这时配 LLM 提取器的收益是明确的,代价是每次写入多一次调用、且抽取结果不再确定可复现。
批量场景下还有一个额外约束:逐条 add() 的开销主要不在抽取,而在每次都要重建索引与追加账本。批量接口把这一部分合并,官方给出的量级是快一个数量级(README 写作约 15 倍)。但批量不是无代价的——它把一批写入放进同一个事务边界,单条失败时的隔离性比逐条写更差,需要靠返回结果里逐条的 event 字段去分辨哪条成功了。
4.1.3 去重的边界
只增不改的系统里,重复是最主要的噪声来源,也是最容易做错的地方。一个每轮都重读自己上下文的 Agent,会在每一轮把同一条偏好再存一遍;十轮之后,库里有十条「用户偏好深色模式」,召回返回五条一模一样的记忆,看起来像检索出了问题。
去重的依据是内容签名——归一化空白、大小写与模板动词之后算出的稳定哈希。同内容同库必然同 id,所以去重不需要额外的索引结构,只需要在写入前比对签名。
但这个比对是有界的。逐条扫描全库的签名,在十万条量级上会把一次写入拖到接近一秒,而「记得越多越慢」比「偶尔存一条重复」更糟。实现上设了扫描上限,超出上限后退回引擎原生的模板哈希版本机制,后者能识别同模板的重复但识别不了改写。代价必须承认:超过扫描上限之后,改写型重复会被放进来。它是概率性的,取决于库的大小与写入位置,所以同一个 Agent 在小库上表现正常、在大库上开始出现近似重复,是预期行为而不是缺陷。
4.2 双时间轴
4.2.1 事实何时发生与系统何时获知
一家跨境物流公司在 2026 年 9 月把 2024 年的历史工单批量导入记忆库,用 add() 逐条灌入。三个月后有人问「2024 年这批货走的哪个口岸」,Agent 答的是最近一次的口岸。原因很朴素:所有记录的时间戳都是导入那天。
一条记忆至少涉及两个时间。事件时间是事实在现实世界里发生的时间;摄取时间是系统得知它的时间。两者在实时对话里几乎重合,所以在实时场景里看不出区别——直到你导入历史数据、补录工单、或者处理用户回溯叙述(「我 2023 年搬去柏林」),两者立刻分叉。
分离的实现很轻:observation_date 显式声明事件时间,写入时间由系统自己打,另外还有一个 knowledge_time 记录「系统何时得知」。三者都在记录里,检索时按需要取用。timestamp 参数则是另一件事——它把 created_at 整体回拨,用于导入转录文本时让历史顺序正确,而不是所有记忆都盖着「现在」。
两者的差别在于回答哪一类问题。问「他现在住哪」要用摄取时间的最新值;问「2024 年他住哪」要用事件时间落在区间内的那一条;问「我们是什么时候知道这件事的」要用 knowledge_time。三个问题在同一个库上,答案可以完全不同,而且都是对的。
代价是查询侧多了一个必须做的判断:这次问的是「当时是什么」还是「现在是什么」。写错一边,答案就是错的,而且错得很像对的。上面那家物流公司的修复方式不是改数据,而是在检索入口区分两类问句——问历史走向时按事件时间过滤,问现状时按版本链取最新。这个判断目前靠调用方显式声明,系统不会替它猜;猜错比不猜更危险,因为它会以确定的口吻给出错误的答案。
4.2.2 按实体组织的版本链
双时间轴解决「什么时候」,版本链解决「哪一个」。同一条事实被更新时,旧记录不被删除,而是标记为已被取代,并留下双向指针:旧条目的 superseded_by 指向新条目,新条目的 supersedes 指向旧条目。沿这些指针走,就能还原一个实体在时间上的取值序列。
保留旧版本不是为了怀旧。它同时支撑三件事:回答「2024 年他在哪家公司」;解释一次判断为什么在当时是合理的;以及在发现新条目本身是错的时候回退。删掉旧条目等于抹掉事件时间轴,记忆就退化成一张只有当前值的键值表。
版本链还有一层不那么明显的用途:它让「变化」本身成为可检索的内容。「用户以前喜欢详细解释,后来嫌啰嗦」这个变化,比任何一个时间点的偏好都更有信息量,而它只存在于两条记录的差里。
代价有两个。第一,检索必须知道「被取代的条目默认不返回,但显式问历史时要返回」,这个开关漏在任何一条读取路径上都会产生自相矛盾的答案。第二,版本链把「当前状态」变成一次遍历而不是一次读取,实体被改得越多,取当前值的成本越高。第三,写入侧必须保证链不断——一次绕过链的直写会让后续所有回溯停在那一点,而它不会报错。
4.3 分型
4.3.1 记忆类型与记忆层
分型回答的是「这条记忆是什么种类的东西」。它不是一个装饰性字段,检索的标签通道、图通道、画像卡的分段、遗忘策略的价值模型都读它。
Mnemosyne OS 8.1.0 的 __init__.py 里定义了五组枚举,取值如下。
| 枚举 | 取值 | 用途 |
|---|---|---|
MEMORY_TYPES | semantic / episodic / procedural / preference / lesson / identity / reflection / strategy,另加待办、笔记、会话、事实、事件五类记账型取值,共 13 种 | 决定召回过滤与画像分段 |
MEMORY_LAYERS | working / episodic / semantic / procedural / reflective | 决定巩固路径的起点与终点 |
FACT_TYPES | fact / opinion / belief / observation / inference / hypothesis | 决定冲突判定是否可比 |
SOURCE_TYPES | user / system / inference / web_search / file / agent_generated / external | 决定初始置信度与准入 |
VERIFY_STATUS | unverified / verified / contradicted / outdated / superseded | 决定召回时是否提示「已被取代」 |
表 4-3 五组分型枚举的取值与用途
这里有一处必须如实写出的不一致:MEMORY_TYPES 里的取值拼作 reflection,而 MEMORY_LAYERS 里的对应层拼作 reflective。两者不是同一个东西——前者是「一条反思类记忆」,后者是「记忆所处的层」。写代码时把两者当成同一个键去查,会得到一个静默的空结果,因为字典查不到只会返回默认值,不报错。
分型的实际负担在提取器身上。调用方显式给了 memory_type 就用调用方的值;没给时由抽取出的类别映射过去,层则由记录字段推断。类别越多,映射表越长,错分的概率越高。错分的后果不是丢数据——记录还在——而是它落进了错误的过滤维度:一条被标成 note 的偏好,在按 preference 过滤的画像卡里永远不出现,而画像卡看起来一切正常。
4.3.2 事实类型、来源与核验状态
这三组枚举合起来回答一个不同的问题:这条记忆值不值得信。
FACT_TYPES 的核心作用是限定可比范围。冲突判定只在同类断言之间进行——一条 hypothesis 与一条 fact 说法不同,不构成冲突,因为前者本来就不是断言。observation 与 inference 的区分更细:前者是「观察到用户点了三次退款」,后者是「推断用户对价格敏感」,两者都能进库,但后者不该被当成事实去覆盖前者。
SOURCE_TYPES 决定初始置信度,也决定准入。用户直接说的话、系统配置、本地文件属于可长期保存的来源;模型推断、外部检索结果属于需要复核的来源。web_search 单列一项,是因为它是最典型的注入面——检索回来的文本可能是别人写的指令。
VERIFY_STATUS 是给下游看的信号,不是给写入看的开关。superseded 表示「有更新的说法」,contradicted 表示「与另一条明确冲突且未消解」,outdated 表示「时间上已过期」。召回时把这三类与 verified 混在一起返回,模型会把它们当成同等事实使用——这是记忆系统最隐蔽的一类错误,因为结果看起来完全正常。
这里有一条设计取舍值得指出:VERIFY_STATUS 不参与默认过滤,而是随结果一起返回,让调用方自己决定要不要用。这样做的好处是信息不丢,坏处是默认行为不安全——不读这个字段的接入方,会把一条 contradicted 的记录当成事实用。如果把过滤做在服务端,则会出现另一种失效:用户明确问「以前那条说什么」时,被取代的条目被静默过滤掉,答案变成「查不到」。两种做法都不完美,当前的选择是「把判断权交给调用方并确保它有依据」。
4.3.3 分型带来的维护成本
分型是一套需要长期维护的约定,不是一次性的配置。三处成本在部署半年后会显现。
第一处是枚举膨胀。每加一种记忆类型,所有按类型过滤的读取路径都要检查一遍,漏一处就有一类记忆在某些视图里消失。第二处是历史数据的重分型。分类规则改了之后,旧记录仍带旧类型,除非主动回填,否则新旧记录在同一个过滤条件下表现不一致。第三处是跨系统对齐——如果下游还接了另一个记忆系统,两边的类型名不可能一一对应,映射表就成了一个持续漂移的中间层。
一个务实的做法是只使用少数几种类型,把细分信息放进标签而不是类型。类型决定的是「走哪条处理路径」,标签决定的是「属于哪个话题」,前者的分支数是有限的,后者可以自由增长。把细粒度语义塞进类型,等于让一个有限的分支结构去承担无限的分类需求。
4.4 冲突消解
4.4.1 六类冲突
早期实现把「两条记忆提到同一个实体」当成冲突,实测下来误报率高到让整个冲突面板失效:一个 66 条活跃记忆的库报出 14 组冲突,逐条核对后 13 组是同一个对象的不同侧面——「规则注入槽是 A」「记忆通道是云端」「版本从 5.8.3 升到 5.8.4」,三条互不矛盾却被判成一组。
失效的不是告警数量,是告警的可信度。用户发现 14 组里 13 组是噪声之后,会学会忽略整个面板,真正需要处理的那一组也被一起忽略。
修正后的判据是断言对齐:只有共享实体、共享谓词、且值或极性不同才算冲突,再细分为六类。
| 冲突类型 | 判定条件 | 动作 | 例子 |
|---|---|---|---|
| 时间性更新 | 属性相同、取值不同 | UPDATE | 换公司、换负责人、搬家 |
| 偏好变更 | 偏好类且宾语不同 | UPDATE | 「喜欢 Redis」→「改用 Postgres」 |
| 作用域差异 | project 或命名空间不同 | ADD | 项目 A 的负责人 vs 项目 B 的负责人 |
| 精度差异 | 数值不同但事件时间不同 | NOOP | 「约 3 个月」vs「92 天」 |
| 逻辑矛盾 | 极性相反且共享谓词 | KEEP_BOTH | 「支持 X」vs「不支持 X」 |
| 来源冲突 | 同一事实、不同来源 | KEEP_BOTH | 用户口述 vs 检索结果 |
表 4-4 六类冲突的判定条件与默认动作
判定的关键在于「谓词」这一层。实现上取第一个谓词的位置,从它前面回溯一小段文本作为属性名,后面作为取值:用户在北京的公司是甲公司 与 用户在北京的公司是乙公司 属性同为「北京的公司」,值不同,判为时间性更新;而 规则注入槽是 A 与 记忆通道是云端 属性不同,直接判为不冲突。这条判据把上面那 14 组噪声压到 0。
作用域差异这一类值得单独说明,因为它不是「不是冲突」,而是「看起来像冲突但其实不是」。项目 A 的负责人是张三、项目 B 的负责人是李四,两条记忆共享实体「负责人」、共享谓词、值不同,但作用域不同。判成更新会把项目 B 的负责人写丢。所以判定链的第一条不是看内容,是看作用域。
代价是它对分词与谓词表有依赖。中文实体抽取本身是噪声源,长串会被硬切成碎片,所以属性名不能直接复用实体字段,得单独从谓词位置回溯。谓词表漏掉一个词,一对真冲突就会退化成「不冲突」——这是漏报方向的风险,比误报更难发现。
4.4.2 四种操作与全库审计
判定之后是动作,只有四种:ADD(无冲突,正常新增)、NOOP(重复或细化,不写)、UPDATE(旧条目失效)、KEEP_BOTH(两条都留)。
UPDATE 的落法是置 superseded 并写 superseded_by,不删除。理由在 4.2 已经给过:删除旧条目会抹掉事件时间轴。这也是主流图记忆系统的共同选择——边失效而非删边。
KEEP_BOTH 的落法是两条都留、新条降权、标 needs_confirmation。不自动选边的理由是:没有额外证据时站边,一旦站错就是把错误固化成事实,而它之后会被当成 verified 使用。
NOOP 不是「什么都不做」。它需要判断哪一条信息更全,保留更全的那条;判断错了,细化信息会丢。这条操作最容易被实现成「直接丢弃新的」,于是后来补充的细节永远进不来。
写入路径默认不消解。所有 UPDATE / DELETE 类动作都要显式调用 conflict_resolve,且先跑 dry_run=true 看计划。
python
from mnemosyne import Memory
m = Memory()
plan = m.conflict_resolve(content="用户在北京的公司是丙公司", dry_run=True)
print(plan["plan"]) # UPDATE / NOOP / KEEP_BOTH / ADD
print(plan["verdicts"]) # 每条候选的冲突类型与理由计划里会列出「与哪条冲突、判成哪一类、打算怎么做」,看完再以 dry_run=False 执行。把执行放在第二步不是流程洁癖:UPDATE 会改变后续所有召回的结果,而它的判据依赖谓词表,一次误判会静默改变历史答案。
这个默认值是有代价的:库会持续积累互相矛盾的条目,如果没有人定期跑审计,冲突面板就永远不会被打开。所以 conflict_resolve 提供 scope=audit 做全库扫描——按实体分桶、桶内两两判定、只保留非 NONE 的结果;桶内超过 12 条时按置信度取前 12,避免大库上复杂度失控。
审计本身也有边界:分桶意味着没有共享实体的冲突扫不到。两条记忆如果一条说「甲方案是安全的」、另一条说「该方案不安全」,而实体归一化没能把它们并进同一个桶,审计就看不见这一对。返回结果里带上「扫了多少条、报了多少条」正是为了让这个盲区可被判断,而不是让人误以为「0 条冲突」等于「库是干净的」。
4.5 多模态写入的不丢弃原则
一家保险公司的理赔 Agent 收到用户发来的行驶证照片。系统没有配置视觉模型,于是照片被存成一条「记忆」,正文是图片 URL,元数据标了 modality: "image"。两周后有人问「行驶证上的车牌号是多少」,召回为空——因为那条记忆里没有一个字是车牌号。
这个结果可以接受,但前提是它必须可见。不丢弃原则的含义不是「存下来就能用」,而是「存下来并且让人知道哪些被理解了、哪些没有」。配置了视觉模型时,写入的是描述,图片内容因此可以被语言召回;没有配置时,写入的是引用,调用方从元数据能看出这条只是引用。
三种图像格式在接入侧各不相同,解析层都要认:OpenAI 的多段内容({"type": "image_url", "image_url": {"url": ...}})、Anthropic 的({"type": "image", "source": {...}})、Gemini 的内联分片({"type": "image", "data": ...}),另有 input_image 简写。音频走同一条通道。
python
from mnemosyne import Memory
m = Memory()
m.add([{"role": "user", "content": [
{"type": "text", "text": "My new desk."},
{"type": "image_url", "image_url": {"url": "https://example.com/desk.jpg"}},
]}], user_id="alice")三种格式混在同一个客户端里提交是常态——前端 SDK 用 OpenAI 形状、后端直连 Anthropic、批处理走 Gemini 的内联分片。解析层如果只认一种,其余两种会被当成纯文本,图片引用连同它承载的信息一起消失,而且不会报错。
有一条规则值得单独说:写入时不下载远端资源。图片引用按原样保存——http(s) 链接、data: URI、或本地路径。在记忆写入里发起网络请求,会把一次文本操作变成一次有超时、有重试、有部分失败的网络操作,而失败面是无穷的。
还有一条结构性规则:一张图不单独成为一条记忆。没有说明的裸图片,Agent 无法用语言召回,把它存成独立记录只会得到一堆不可召回的条目。做法是把它作为附件挂在它伴随的那条文本记忆上;只有当整轮输入只有图片、没有任何文本时,才单独落一条以引用为正文、以 modality 标注类型的记录。这个例外是必要的——否则「用户只发了一张图」这件事本身会被丢掉。
代价同样明确。只存引用的记忆无法被语义召回,只能靠标签、时间或元数据命中;而存了描述的,描述本身是模型生成的,描述错了记忆就错了,且错得很像对的。两个方向都不能免费。
4.6 中文写入的工程细节
中文在写入侧的问题不是「支持不支持」,是「支持到什么程度」。四个层面各自有具体的取舍。
分词。 中文没有空格,标准做法是 bigram:单字成词之外,相邻两字也成词。索引里存的不是原始句子,而是空格分隔的 bigram 序列,查询侧同样切 bigram 后走 FTS5 的 MATCH。这样「苹果」既能被「苹」「果」命中,也能被「苹果」命中,不依赖分词词典。代价是索引体积显著膨胀,且会产生跨词边界的伪词——「果苹」这种在原句里不存在的组合也会进索引,带来少量误命中。
同义词。 查询词与标签不同形时,标签通道会完全失效。实测过的例子:问「我的显卡是什么」,标签是「硬件」,两者不同形,标签加权拿不到,分数反而被无关查询反超。解法是一张内置同义词表,把查询词映射到标签族(显卡 / GPU / 显存 → 硬件)。代价是这张表要人工维护,覆盖不到的词仍然失效,而失效是静默的。
拉丁脚本。 中英混写是常态,代码标识符、型号、URL 都必须在同一套分词里完整保留,不能被 CJK 规则切碎。RTX 4090、INV-2024-001 这类串一旦被切碎,既无法精确匹配,也无法在嵌入里保持语义。
相对时间。 「上周三」「去年底」这类表达必须落成绝对日期,否则时间通道无法比较。实现上是一个偏移表:上周 → 减 7 天,去年 → 减 365 天,另有「N 天前 / N 周前 / N 月前 / N 年前」的正则形式。
这里必须承认它的粗糙:减 365 天不是「去年」,跨闰年会偏一天,而「去年底」这种表达无论怎么算都只能给一个近似值。对「去年他在哪家公司」这类问题,一天误差无所谓;对「上周三签的合同什么时候到期」这类问题,一天误差就是错的。
四层里最容易低估的是第二层。分词与拉丁脚本是技术问题,做不好会立刻暴露;同义词表是覆盖问题,做不好只会让某些问题悄悄答不上来,而系统一切正常。中文场景下「某些问题」的比例不低——凡是提问用词与记忆用词不同形的,都落在这个缺口里。
4.7 本章小结
本章确立了三条判断。
第一,单遍写入是一个关于失败形状的选择,不是关于速度的选择。 追加是单调的,坏抽取只能引入噪声,而噪声可以被排序和阈值压住;更新是非单调的,坏更新会静默抹掉真事实,且事后不可区分。这条判断成立的条件是:系统具备把「两条说法不一致」留到读取侧处理的能力(冲突消解、核验状态、版本链)。缺少其中任何一项,矛盾条目就会以同等权重出现在召回结果里,此时单遍写入的代价从「噪声」升级为「自相矛盾」。它会失效的场景是高频覆盖型数据——例如库存数量、在线状态——这类数据本来就只关心当前值,为它保留版本链是纯粹的浪费。
第二,双时间轴不是可选字段,是能不能回答历史问题的分界线。 只有摄取时间的库,对「2024 年他在哪家公司」只能给出最后一次写入的值,而它看起来像个正确答案。这条判断的成立条件是查询侧真的区分了两类问句;否则字段存在但没人用,等于不存在。它失效的场景是纯实时系统——如果这个库从创建起就只处理当下发生的事,两个时间轴永远重合,分离带来的只是两个必须同步维护的字段。
第三,冲突消解的价值在可信度,不在召回率。 一套报 14 组、其中 13 组是噪声的检测,实际效果不如一套只报 1 组的检测,因为前者会训练用户忽略告警。断言对齐(共享实体 + 共享谓词 + 值或极性不同)是当前能把误报压到可用的判据,代价是它对谓词表与实体归一化有依赖,漏报方向的风险更高。它在小库上收益最大:库越小,一次误判对整体答案的影响越大;当库大到单条记忆的影响被稀释时,全库审计的成本会先于收益到达上限。
一个未解决的问题留到第 5 章:本章的所有机制都建立在「记录会一直留在库里」之上——版本链要保留旧条目,冲突消解要能扫到旧条目,审计要能回溯。而只增不改的存储会一直长。当库大到一次全库扫描不可接受时,是降低扫描频率、缩小扫描范围,还是把旧条目迁到另一个存储层再扫?这个问题的答案决定了分层策略的形状。