我们如何在 Elasticsearch 上构建一个持久 agent 记忆层,实现 0.89 召回率和零租户泄漏
作者:来自 Elastic Noam Schwartz
发现基于 Elasticsearch 构建的持久化、多租户 agent 记忆层架构:三个索引、结合 RRF 和重排序器的混合检索、supersession、衰减机制以及按用户的 DLS(Document-Level Security) 隔离。在 168 个问题上 R@10 达到 0.89。包含完整开源实现。
Agent Builder 现已正式发布。通过 Elastic Cloud 试用 开始使用,并查看 Agent Builder 的文档 这里。
在 Elasticsearch 上构建 agent 记忆
三个索引、结合 reranker 的混合召回、替代机制(supersession)、衰减机制以及 DLS。这是为 agent 构建持久记忆层的架构与背后指标说明。
Sarah 的智能灯泡只显示白光。她的智能家居助手建议重置 hub(集线器)。她在三月做过一次重置,上周又做了一次;两次重置都没有解决问题。agent 并不知道这些,也不知道她的狗咬断了传感器电缆这件事。那些重要的历史信息 —— 什么有效、什么无效、以及 Sarah 是谁 —— 都随着每个 session 结束而消失了。
常见的变通方法是把之前的上下文塞进 context window。但这在成本、延迟(latency)以及众所周知的 “lost in the middle(中间遗失)” 效应上都会失效,在该效应中,模型会忽略那些位于提示词两端之外的事实。1M token 的 context window 也只是一个便签板(scratchpad),而不是一个记忆系统。
context window 是短期记忆:用于单次 inference 的活跃推理空间。而缺失的是长期记忆:一个持久化存储,可以跨 session 存在、支持多年交互,并且能够按内容、时间和用户检索事实。
这篇文章讲的是一个真实 agent 记忆系统的架构,构建在 Elasticsearch 之上,并围绕来自认知科学(cognitive science) 的三类结构设计,一个结合 RRF 和 cross-encoder reranker 的混合 recall query,用于矛盾处理的覆盖替代,以及按用户的 DLS 隔离。在一个包含 168 个问题的 QA 评测中,R@10 平均达到 0.89,且没有跨租户泄漏。
完整实现已开源在 GitHub;本文重点在于解释它为什么会被设计成这样。
Agent 记忆存储需要做什么
当用户问:“我们上次尝试了什么修复?” 这是一个带时间约束的精确匹配查询。或者问:“为什么我的智能灯泡只显示白色?” 这需要把个人记忆与共享目录信息结合起来。记忆本身并不是统一行为:用户经历过的事件、关于用户的稳定事实,以及一步一步的操作流程,它们各自有不同的写入频率和衰减规则,因此存储系统必须识别类型并分别处理。
在任何多用户部署中,每个用户的记忆都必须对其他用户完全不可见。新鲜事件会快速累积,如果不进行归并,就会把索引变成一堆无序信息。用户一旦和已检索事实产生矛盾,旧版本必须通过覆盖替代(supersession)而不是删除来处理,以便保留审计轨迹。旧事实不应压过新事实,用户频繁触达的事实也不应下沉。整个 memory 层还应该可以被任何支持 MCP 的 client 访问,而不是绑定在某一个 agent runtime 上。
如果把这些能力拆分到一个 vector store、一个关键词引擎、一个审计层以及一个独立认证服务中,就会变成四个可能出错的系统,并且每次 recall 都要额外增加往返开销。这些需求本质上描述的是一个 search engine,因此这个实现直接使用一个系统完成。后文将逐一展开。
Agent 记忆的三种类型:情景记忆、语义记忆、程序记忆
第一个设计决策是:到底要存储哪些类型的记忆。如果只是把所有东西都存起来,就会变成没有信号的干草堆。来自认知心理学(cognitive psychology)的 情景记忆(episodic)、语义记忆(semantic)、程序记忆(procedural)划分,在 COALA 框架中已经被用于 LLM agent,这一分类本身就非常合适,并且可以直接映射到三个 Elasticsearch 索引。
情景记忆(episodic memory):带时间戳的事件。每一轮用户输入在进入系统时就被记录下来,尚未经过抽取或解释。其中大部分是短期的,不一定需要长期保存,只有少部分会成为后续稳定事实的证据。
语义记忆(semantic memory):被提炼后的稳定用户断言。例如:Sarah 拥有 Lumio Hub v2,Sarah 使用 iOS 17.4,Sarah 的 hub 在三月被重置。这些信息跨 session 存在,是 agent 进行 grounding 的基础。
程序记忆(procedural memory):多步骤操作流程。例如 Zigbee 断连的排查步骤。它不是事实,而是过程。每条流程都会携带 success_count 和 failure_count,并在 consolidation 时根据用户是否确认修复成功来更新。这些计数会作为上下文提供给 LLM,用于判断是否需要优化或替换该流程。
每一种 memory 都有不同的生命周期。情景记忆持续写入并衰减;语义记忆会被整理、去重并在用户信息变化时被覆盖替代;程序记忆则通过 success_count 和 failure_count 累积反馈,用于指导 consolidation。单一结构无法表达这些差异,因此采用三个索引,每个 memory 类型拥有独立的写入频率、衰减规则和更新机制。
在这三类之外,还有第四种检索(retrieval)数据源:已经存在于 Elasticsearch 中的世界数据(目录、知识库)。它在认知意义上不属于 “记忆”,但 agent 通过同样的 hybrid retrieval pipeline 访问它(下一节会讲),因此在整体架构中被统一纳入同一视图。
检索管道:基于 RRF 和 reranker 的混合检索
Memory 的召回通过一个两阶段的 hybrid search 实现:在BM25 + Jina v5 dense上做 RRF 融合,然后对合并后的候选集用 cross-encoder reranker 重新排序。每一条document在一次写入中被双重索引:原始文本进入 BM25 的inverted index,同时通过copy_to把同样的值路由到semantic_text字段,由此自动生成 Jina v5 向量。Indexing同一份内容两次并不会增加存储负担:一次 source-of-truth 写入同时产生两条检索路径(index mapping)。每条路径解决不同问题。BM25 捕捉字面 token 匹配,这些信息在 agent 改写问题时会丢失:版本号、错误码、像 “Lumio Hub v2” 这样的专有名词。dense 向量捕捉语义结构,即使表达不同也能匹配问题与答案。单独任何一条路径都会遗漏另一条能覆盖的情况,而 RRF 在无需对 BM25 分数和 cosine 相似度做标定的情况下融合排序结果。
Over-fetch。reranker 只能对已有候选集重排序,因此候选集必须足够大。混合检索器每条路径取 80 个候选,并用 RRF 进行融合,rank_constant=30(比 ES 默认 60 更紧,使得高排名条目权重更集中)(``rrf_fetch``)。
Reranker。使用 Jina v2 cross-encoder对合并后的候选与用户查询进行打分。BM25 和 bi-encoder dense 是分别独立对 query 和 document 编码,而 cross-encoder 会对二者联合建模,在完整 attention 下计算相关性,效果更强但计算成本更高。这正是两阶段 pipeline 的原因:先用便宜的 hybrid retriever 做 over-fetch,再用更昂贵的 scorer 对小候选集 rerank(``rerank``)。
有一个细节,如上图所示。agent 的工具集包含 recall_memory(定义在 tools.py),模型在一次对话中会调用它。一次调用会同时横向检索所有三个 memory index 和 catalog:agent 不需要选择 memory 类型,因为 retriever 的 ranking 和各 index 的时间衰减已经完成路由。第二个细节是改写(paraphrasing)。agent 几乎总是会先改写用户消息再调用工具,这会在 BM25 看到 query 之前剥离版本号、错误码和专有名词。因此每一轮都会先对用户原始消息做一次自动 pre-recall,把结果注入对话中,仿佛 agent 自己已经调用过该工具(``)。
写入与合并 agent memory
有两个操作把 memory 从“刚发生的事件 ” 变成 “长期保留的信息”。
Write。每一轮用户输入都会先写入一条 episodic event(ID、原始消息、时间戳等),然后 LLM 才开始响应。ID 由 Elasticsearch 写入时生成,DLS(Document-Level Security) 查询通过 Sarah 的 API key 在后续每次 recall 中限制数据范围,时间戳用于下面的 time-decay 排序函数。agent 的回复不会被存储,因为对话历史已经会在下一轮输入中携带这些信息,而且回复内容通常冗长,会稀释用户提供的短但高价值事实。选择在 hot-path 写入是刻意的。两种看似合理的替代方案都存在问题。把新事实留在 context window 中确实能覆盖当前 session,但一旦 session 结束或崩溃,in-context state 就会消失,而跨 session memory 正是目标。
在 session 结束时 batch 写入可以保留跨 session 状态,但会破坏同一轮中的两类关键流程:用户在同一条消息中提到新设备并查询设备列表时,新事实必须在同一 turn 后续 recall 中可见,因为 tool call 查询的是 index 而不是 conversation history;supersession 流程也会在同一 tool-call batch 中写入修正事实并立即 recall。如果延迟写入,这些模式会静默出错。因此选择每条用户消息一次 Elasticsearch 写入,成本是可控的(单次通常低于 100ms)。
“哪些建议有效” 被单独记录在 procedural index 中的success_count/failure_count,而不是保存完整回答文本。最近的带用户确认的事件(“谢谢,这有效”)会触发success_count++;明确否定(“没用”)触发failure_count++。对话本身作为反馈信号,由 consolidation LLM 做分类,不需要点赞组件。分歧还会生成refined_steps字段回写到流程中。
Consolidate。episodic logs 增长很快,因此需要把它们提升为 semantic facts 和 procedural playbooks,使其在对话历史消失后仍然存在。该实现每轮都会运行一次(用于观察 inspector 实时更新),但在生产中更合理的节奏是后台任务:每 24 小时一次,或当用户 episodic index 超过 N 条事件时执行。逐轮执行会让 LLM 调用次数翻倍。
在一次调用中(prompt),consolidation LLM 会接收最近事件 + 已有 facts 和 playbooks,并生成三类输出:
新的 semantic facts,包含
supporting_episode_ids用于溯源新的 procedural playbooks,当多步骤解决方案不匹配已有 trigger 时生成
procedural updates,基于用户反馈更新
success_count++/failure_count++,以及在用户否定时生成 refined_steps
Prompt 要求每条输出都必须包含supporting_episode_ids,因此没有信息支撑的 turn 会返回空列表,不会写入任何内容。
Dedup 使用与 recall 相同的 hybrid retriever:对每个候选 fact 先做 top-K hybrid search 缩小比较范围,再交给 LLM 判断语义是否重复。还有两个额外保护条件:低于 confidence 阈值的候选会被丢弃;如果最相似匹配 ≥ 0.90,则视为重复。在这个实现中,dedup 更简单:只把最近约 50 条 facts 交给 consolidation LLM,并提示 “不要重复”,而 post-LLM 的 confidence / similarity guard 尚未接入。hybrid-recall 和 guard 是生产架构,这个版本依赖 LLM 直接比较,因为corpus足够小。
success_count和failure_count形成 playbook 的反馈闭环:随着对话增多,同一字段逐渐变成“下次优先展示哪个方案”的信号。目前这些计数已经写入,但还没有影响 retrieval ranking。在少量已解决 ticket 上,这个信号还只是统计噪声;在正式生产中,随着数据量增加,它会变得有意义。
Agent 如何处理矛盾与 supersession 的 memory
只会不断追加、不会删除的 memory,最终一定会出错。用户说 “我搬到 Edinburgh”; agent 写入一个新事实。六个月后,旧的 “住在 Bristol” 的事实仍然留在 index 里。两者都会在每次 recall 时被检索出来, agent 要么选错,要么只能含糊处理。信任会很快崩塌。
解决方法是在 system prompt 里加一条规则(full prompt),不引入新工具。不是删除,而是让 agent 进行 supersession:
If the customer contradicts a recalled fact, call write_memory(text=<new>, supersedes_id=<old id>, contradiction="harsh"|"natural"). Use contradiction="harsh" when the customer explicitly denies or corrects the prior fact ("no, that's wrong", "I never X"). The new fact carries a small confidence penalty. Use contradiction="natural" for routine updates (moved, upgraded, preference change). The new fact is written at full confidence. Never ask the customer to confirm before superseding. The contradiction itself is the signal. forget_memory is only for explicit "forget this" requests.一个完整示例
Sarah 的上一次访问记录了id=abc,“Sarah lives in Bristol”,写入 semantic index。三个月后,她打开对话:“we left Bristol, in Edinburgh now.”
1. 召回(Recall)。对 Sarah 的消息进行 pre-recall,返回命中结果,包括 {id: "abc", text: "Sarah lives in Bristol", memory_type: "semantic"}。
2. 检测(Detect)。agent 发现被召回的旧事实与新消息之间存在冲突。
3. 分类(Classify)。“we left Bristol, in Edinburgh now” 被判断为自然更新,而不是否认,因此选择contradiction="natural"。
4. 写入(Write)。agent 调用
``write_memory``(text="Sarah lives in Edinburgh", supersedes_id="abc", contradiction="natural")。这一操作会同时发生两件事:
写入一条新文档
id=xyz,并保持完整置信度(因为是自然变化,不做惩罚)旧文档
abc被更新为superseded_by=xyz, superseded_at=<now>
5. Recall 隐藏旧数据(Recall hides the old)。每次 recall 都会应用一个过滤规则(filter must_not exists field=superseded_by)。因此abc会从 agent 视野中隐藏,xyz正常返回。
6. 审计保留(Audit kept)。文档abc仍然保留在 index 中。通过查询superseded_by=xyz可以重建完整链路。
注意:如果 Sarah 后续问“我住过哪些地方?”,agent 会调用recall_memory(query="places sarah has lived", include_superseded=True)。由于 DLS 作用域限制,这次查询会同时返回xyz(Edinburgh)和abc(Bristol)。带有superseded_at的记录表示历史状态,agent 在回答时会区分它们(实现位置):
“你现在住在 Edinburgh;你之前住在 Bristol(直到今年早些时候)。”
如果 Sarah 说的是:“I never lived in Bristol, that was my sister”,第 3 步会被分类为harsh。写入流程相同,但新事实的置信度会被SUPERSEDE_CONFIDENCE_PENALTY降低,使系统在新状态未被充分确认前保持一定谨慎。
边界情况遵循同样结构:一个已被 supersede 的事实可以继续被 supersede(abc → xyz → pqr);低风险偏好(例如 “我现在更喜欢 dark mode”)同样按contradiction="natural"处理。forget_memory才是硬删除机制,只在用户明确要求 “忘掉 X” 时使用,不用于普通冲突更新。
还有一个关键细节:一次 recall 可能命中多个与新陈述冲突的事实。例如 Sarah 的位置可能同时存在:
“Sarah lives in a Victorian flat in Bristol”(semantic)
“Sarah has a flat in Bristol where her Hub v2 is”(semantic)
以及某次 episodic event
agent 必须对所有冲突事实执行 supersede,而不是只处理第一个命中的结果。对每个被新陈述否定的 id,都要分别调用write_memory(supersedes_id=…)。但那些只是 “提到 Bristol、但仍然成立” 的信息不会被覆盖,例如:
“Bristol 的维多利亚式公寓墙体很厚,会削弱 Zigbee 信号”
这类知识仍然有效,因此不应被 supersede,因为它不依赖“是否住在 Bristol”。
已 supersede 的文档默认不会出现在普通 recall 中,只有在include_superseded=True时才会返回。在生产环境中,这类数据通常通过 Elasticsearch 的 ILM(Index Lifecycle Management)迁移到冷/冻结层(searchable snapshots)。审计链仍然可查询,但活跃 semantic index 保持小而高效。
确保 Elasticsearch agent memory 的同一 turn 写入可见性
Elasticsearch 默认的异步 refresh 间隔会在 agent 写入 memory 并在同一 conversation turn 立即进行 recall 时产生传播延迟。当用户在同一条消息中说:“I have a Lumio Range Extender I never set up. Now what's my complete device list?” 时,agent 会先写入 Range Extender 这一事实,然后立刻执行 recall,这一过程发生在同一个 turn,甚至可能在同一个 tool-call batch 内。默认的 Elasticsearch refresh 间隔,加上semantic_text的 inference 计算成本,可能导致亚秒级传播延迟,使刚写入的文档在 recall 时尚不可见。
解决方案在存储层。每一次 agent 触发的write_memory都会传入 `` refresh=True``,强制 shard 在返回前 refresh(同时 inline inference processor 生成的 Jina v5embedding也会完成落盘)。这样下一次 tool call 就能看到新写入的文档。Range Extender 会出现在最终回答中,因为紧随写入之后的 recall 已经能检索到它。
在更高写入量下,refresh=True会带来throughput(吞吐)成本。生产环境可能会倾向于切换为异步 indexing,同时在 agent 层维护一个 “刚写入记录表”,把新写入内容暂存在 LLM context 中,直到 index 追上为止。目前这种更简单的方案在系统中仍然占据合理位置。
Agent 记忆检索中的时间衰减与使用次数评分
目前为止的检索方案对所有事实赋予相同权重,而不考虑它们的创建时间或最近使用时间。这并不是一个合理的默认策略。一个在过去一周被召回两次的事实,几乎肯定比一个两年前只提过一次的事实更相关。
因此,我们在每个结果的评分上乘以两个权重因子:一个主导的时间衰减(time-decay)信号,以及一个辅助的使用频率优化信号。时间衰减是一个高斯形状的乘子,在 Painless 中基于每个 index 的日期字段计算(见下文)。使用频率优化是一个use-count boost(1 + log10(1 + use_count) * weight),大致效果是:被召回 10 次的事实约提升 1.2 倍,被召回 100 次的事实约提升 1.4 倍。
这两个机制回答的是不同问题:时间衰减回答 “这个事实最近是否被使用”,使用次数回答 “这个事实被使用了多少次”。当多个事实共享相同的last_used_at时,两者就会产生分化:衰减无法区分 “只用过一次” 和 “用过四十次”,但 use-count 可以。时间衰减是基础信号;use-count 是在召回规模足够大后才变得有效的增强信号。
每种 memory 类型的时间字段
episodic 和 semantic 使用不同的时间字段。episodic 使用timestamp(事件发生时间),semantic 使用last_used_at(写入时设置,并在每次 recall 时更新)。Elasticsearch 原生的gauss无法跨字段工作,因为该函数要求所有 index 必须存在同一个字段名。因此时间衰减必须在 Painless 脚本 中实现:根据 index 类型选择不同字段,并在运行时计算高斯衰减值。
procedural memory 被刻意排除在时间衰减之外。因为last_used_at会在每次 recall 时更新(无论成功与否),如果直接使用衰减函数,会奖励“最近被尝试过”,而不是“最近有效”。更合理的设计应该是引入last_success_at,并结合success_count/failure_count来参与排序。在这些字段尚未完整接入之前,仅靠时间衰减会过于粗糙。
semantic 上的 recall-time bump 是整个机制的关键。它将“旧事实权重下降”转化为“长期未被使用的事实权重下降”。这是相关性衰减,而不是事实衰减。事实是否仍然真实由 supersession 机制处理;而一个五年前的事实如果仍然被频繁召回,它依然会排在前列,因为last_used_at是最新的。
这也对应认知科学中的同一机制:retrieval practice(检索练习)会增强记忆可用性,而长期不使用会导致衰减。对last_used_at的召回时更新,本质上是这一机制的工程实现。
检索时的乘子(retrieval-time multiplier)
两个因子最终都会汇入同一个function_scoreblock,并包裹在每一条 RRF 分支之上:
{ "function_score": { "query": "<bool query: text/semantic match + filters>", "functions": [ { "filter": {"terms": {"_index": ["atlas_memory_episodic", "atlas_memory_semantic"]}}, "gauss": {"last_used_at": {"origin": "now", "scale": "1825d", "offset": "180d", "decay": 0.5}} }, { "filter": {"term": {"_index": "atlas_memory_semantic"}}, "script_score": {"script": "1 + log10(1 + use_count) * 0.2"} } ], "score_mode": "multiply", "boost_mode": "multiply" } }在代码层面,这两个函数都写在同一个 Painless 脚本里,只是按 index 做分支(同一套数学逻辑,更少的 function_score 条目)。
两个_index过滤器起到双重作用:它们用于限定每个函数应该影响的 memory 类型范围——时间衰减作用于 episodic 和 semantic,而 use-count boost 只作用于 semantic。同时它们也把 procedural 和 catalog 排除在外:如果某个 function 的 filter 不匹配,就返回中性值 1.0,因此即使 cross-index 查询包含这些 index,也不会出现评分或解析问题。完整函数在 ``operations.py``。
两个参数控制 gauss 曲线:
offset(180天):一个平坦区间(flat zone)。小于180天的文档统一乘以1.0的系数,不论具体新旧。否则新事实之间会因为“亚天级别”的时间噪声而互相竞争。scale(1825天,约5年):距离 offset 之后的时间点,在该位置衰减系数达到decay = 0.5。可以理解为“从平坦区结束开始计算的半衰期”。
衰减本身是一个刻意的权衡。当 corpus 中每个事实都是唯一且长期有效时,任何衰减都会带来 recall 损失:旧事实仍然正确,但被人为降权。衰减真正发挥价值的场景是现实情况:多个关于同一对象的事实同时存在,而你希望最新或最常被使用的那个排在最前。
默认 scale(1825天)是保守设置,因为它更不容易误伤长期有效信息。在产品支持这种“事实快速过期”的场景(例如产品频繁迭代的客服系统)可以调小;在个人助手 memory 这种“事实长期稳定”的场景可以调大。这两个参数都只是对 constants.py 的一行修改。
基于 Elasticsearch DLS 的多租户隔离
Document-Level Security(DLS)将隔离规则直接下沉到 cluster 层。每个用户都有一个 API key,其 role descriptor 中包含一个 DLS 查询,只允许访问属于该用户的文档(以及共享 catalog,因为它没有user_id字段)。使用该 key 的 agent 无论执行什么查询,都无法看到其他用户的数据 —— cluster 在返回结果前已经过滤掉了。这是生产级别的隔离保证,由 server 端在每次查询时强制执行。
retriever 还额外在代码层加了一层user_idfilter,作为防御性兜底:防止 index template 配置漂移、role descriptor 被修改但 DLS 条件丢失、或 admin key 被误用等情况。DLS 是架构级保障,这一层只是低成本的 paranoia check。
将共享 catalog 数据接入 agent memory 检索
memory lookup 本质上是一条 Elasticsearch 查询。Sarah 的 API key 的 DLS 规则允许访问user_id == "sarah"的文档;而 catalog 和共享索引没有user_id字段,因此默认对所有人可见。为了把它们纳入同一次检索,DLS 查询从“必须等于 sarah”扩展为:“等于 sarah 或不存在user_id”:即一个bool.should条件,包含user_id == "sarah"或must_not exists: user_id。这样 catalog 与个人 memory 可以在同一次 recall 中一起返回。RRF 融合和 time decay 都不需要改变。
用户初始化 key 的 bootstrap 脚本在 bootstrap script 中生成,并内置了这个扩展后的 DLS 条件。
因此当查询“smart bulb showing only white”时,系统会同时返回 Sarah 的历史约束 + catalog 中关于灯泡兼容性的条目,并在同一个 ranking 里排序。
user memory 和 catalog 可能在同一 recall 中出现并相互冲突。retriever 在同一个 script 内加入一个轻量 source prior(CATALOG_SOURCE_PRIOR,0.85),作为一个额外_index分支实现,不引入新机制,用于在近似相关度相同时偏向 user memory。
这是一个 soft bias,而不是 routing rule:如果 catalog 的 relevance 明显更高(例如产品规格或技术查询),reranker 仍然会选择 catalog。那些“必须始终信任 catalog”或“必须优先用户偏好”的硬规则,则放在 agent system prompt 中,而不是 retriever 层。
通过 MCP 连接任意 agent
memory 层的价值在于不绑定单一 agent。Model Context Protocol 提供了这一能力。endpoint 为/api/atlas/mcp/{user_id},任何支持 MCP 的客户端(Claude Desktop、Cursor 或自定义 agent)都可以通过在配置中粘贴 mcp.py 提供的 JSON 片段接入。
在 Claude Desktop 中,配置文件位于~/Library/Application Support/Claude/claude_desktop_config.json(macOS)或%APPDATA%\Claude\claude_desktop_config.json(Windows)。在 Cursor 中则位于 Settings → MCP。重启后,三个 Atlas 工具(recall_memory、write_memory、forget_memory)会出现在 tool 面板中,调用的是同一套 Elasticsearch indices,与 FastAPI 服务共享同一 memory 层。三种 tool contract 定义在 tools.py 中。
衡量 agent-memory 的召回质量
关于 “recall” 的说明:在本文其他部分,它指的是 memory recall(agent 从存储中检索事实)。而在这里,它指的是信息检索指标中的Recall@K:正确文档是否出现在 top-K 结果中——两者含义不同。
memory 架构很难验证。这里的评估采用 QA 风格的passage retrieval,也就是标准的 RAG 基准测试。对于每一条采样的 document,LLM 会生成两个用户可能提出的问题,这些问题的答案都指向该文档。例如:“my baby's sleep is fragile, anything I should remember when setting up automations?” 会指向 Sarah 关于 nursery quiet-hours 的事实。然后 retriever 必须在 top-K 中检索到对应的源文档。
类似 memory 的通用 benchmark(例如 LoCoMo)已经存在,并且可以用于跨系统对比。但这里选择 corpus-specific QA 模式有两个原因。第一,它针对的是每个 persona 的真实部署语料,因此 recall 数字更贴近真实对话场景。第二,它专注于检索这一阶段(源文档是否进入 top-K),而本文中的 hybrid + decay + reranker pipeline 正是在优化这一部分;而 LoCoMo 的 dialogue coherence 指标则衡量更下游的整体生成质量。
后续文章会运行完整的 LoCoMo benchmark,并进一步拆分 retrieval performance 与 LLM 选择、prompt engineering 等因素的影响。
对于任何多租户记忆系统而言,“泄漏数”(leaks number)是决定能否通过的关键指标,而其余指标则反映了质量水平。在 CI 流程中,评估环节设有硬性门槛(由 eval_recall.py 执行):要求 R@10 ≥ 0.85、R@5 ≥ 0.75 且泄漏数为 0。上述数值均为近似值,因为重排序器(reranker)在服务侧存在波动:在连续四次运行中,R@10 的结果分别为 0.85、0.88、0.89 和 0.893。
Semantic facts 是更困难的情况(R@10 ≈ 0.81);episodic 平均 0.98,而 procedural 命中率 1.0。原因是 sibling collision:一个关于 Sarah 的 hub disconnects 的问题,在 corpus 中可能存在多个“看起来都合理”的事实,retriever 有时会选错那个。
值得注意的是:sibling collision 通常不会降低 agent 的最终回答质量(因为它仍然会拿到一个相关且真实的事实),所以 R@10 对 semantic 来说本质上是保守指标。
Agent memory architecture:关键设计决策
Agent memory 是一组问题,每个问题对应一个关键解法:
memory 不是单一事物。三个 index,对应三个生命周期:episodic(发生了什么)、semantic(什么是真的)、procedural(什么有效)。
LLM 会 paraphrase,削弱关键词精度。每一轮都会先对原始消息做 recall,因此 retrieval 使用 hybrid,再经过 rerank。
append-only memory 会退化。consolidation 会把 episodes 提升为持久 facts;supersession 用于淘汰用户已否认的事实。
旧事实不应该与新事实同等排序。score 会随时间 decay,同时 recall 会将常用事实重新抬升。
多租户必须完全隔离。isolation 由 cluster 内的 DLS 实现,而不是容易遗漏的应用层 filter。
这些并不是独立系统:catalog、isolation 和 decay 最终都组合成一条 Elasticsearch query。
只要这些机制正确组合,那个曾经不断让 Sarah 重置 hub 的 assistant,就会记住:她三月已经试过了,狗会咬传感器线,而家现在在 Edinburgh。
原文:Agent memory on Elasticsearch: hybrid retrieval and DLS - Elasticsearch Labs
