跳到主要内容

文本核心:三阶段语义无损压缩管线

30 秒导读: SimpleMem 的文本这一支,做的是一件事——把源源不断的原始对话,变成一批能被检索、能被独立读懂的记忆条目。它不存原文,而是让 LLM 把每段对话重写成"消解了代词、把'明天'换成绝对日期"的自包含事实句(MemoryEntry),写库时顺手去重,查询时再按意图召回。本章讲透这条主线的三个阶段,并端到端追一次 add_dialogue → ... → ask 的真实调用。

本章是 SimpleMem 文本支的主干。检索的细节(意图规划、三视图召回、反思)在 混合检索;底层存储(LanceDB、FTS、向量表)也在 混合检索 那条边界之外——本章只在"写进 VectorStore.add_entries""从 HybridRetriever.retrieve 取出"这两个接缝处点到即止,不重复它们的内容。总览见 index


1. 这是什么(零基础也能懂)

一句话定义: SimpleMem 是给 LLM agent 用的"长期记忆",文本支负责把对话压缩成语义无损的记忆单元再存起来。

它解决什么问题。 假设你有个聊天 agent,用户跟它聊了几百轮。你不可能每次都把全部历史塞进上下文窗口——又贵又超长。传统做法要么截断(丢信息),要么原样存数据库再全文检索(存的是"他明天来"这种一离开上下文就看不懂的碎片)。SimpleMem 的答案是:存之前先由 LLM 把每句话改写成一个能脱离上下文独立成立的事实

"无损压缩"是什么意思。 这里的"压缩"不是把文本变短,而是去掉对上下文的依赖:

原始对话(有上下文依赖)记忆条目(自包含)
Alice: "Bob,明天下午 2 点老地方见""Alice 于 2025-11-15T14:30 建议在星巴克于 2025-11-16T14:00 与 Bob 会面讨论新产品"
Bob: "行,我准备材料""Bob 同意参会,并承诺准备相关材料"

代词("他/它")、相对时间("明天")、省略的主语,全部被消解成绝对、显式的表达。这样每条记忆单独拎出来都读得懂,检索时也不会歧义。

用起来什么样。 一个最小真实用法(来自 examples/quickstart.py:13-19):

mem.add_dialogue("Alice", "Let's meet at 2pm tomorrow for the project review", "2025-11-15T14:30:00")
mem.add_dialogue("Bob", "Sure, I'll prepare the quarterly report by then", "2025-11-15T14:31:00")
mem.add_dialogue("Alice", "Great, also bring the client feedback from last week", "2025-11-15T14:32:00")
mem.finalize() # 冲刷缓冲、把不满一窗的对话也处理掉
answer = mem.ask("When is the meeting and what should Bob prepare?")

喂对话进去、finalize() 收尾、ask() 提问——中间的切分、抽取、去重、召回、生成全部自动完成。

一句话直觉。 把它想成一个"会记笔记的秘书":你在旁边随口聊,它不抄原话,而是把每件事整理成一条日后单独看也不会误会的备忘,并且随手把重复的划掉。


2. 顶层全景(它大概怎么转)

SimpleMem 的文本支对应论文的三个阶段,代码里写在 SimpleMemSystem 的类注释里(simplemem/text/system.py:20-25):

阶段论文干什么入口
① 语义结构化压缩§3.1滑窗切对话 → LLM 抽取成 MemoryEntry → 写库add_dialogue()
② 在线语义合成§3.2写入时借上一窗上下文去重、保证信息全覆盖(在 ① 内部完成)
③ 意图感知检索规划§3.3提问 → 三视图召回 → 生成答案ask()

注意阶段 ② 不是一个独立步骤,而是嵌在阶段 ①的写入过程里——去重靠的是抽取时把"上一窗生成的条目"喂给 LLM 当参考。

部件职责

部件干什么文件
SimpleMemSystem门面:组装三大模块,暴露 add_dialogue/finalize/asksimplemem/text/system.py:17
MemoryBuilder缓冲对话、滑窗切分、调 LLM 抽取、去重、写库simplemem/core/memory_builder.py:21
MemoryEntry / Dialogue记忆单元 / 原始对话的数据模型simplemem/core/models/memory_entry.py:13:70
VectorStore三视图索引落库(本章边界外)simplemem/core/database/vector_store.py:18
HybridRetriever意图规划 + 三视图召回(见第 2 章)simplemem/core/hybrid_retriever.py
AnswerGenerator从召回的 context 合成简洁答案simplemem/core/answer_generator.py:13

主线走一遍(不进代码)

┌──────────────── 写入侧(阶段 ① + ②)────────────────┐

add_dialogue ──▶ dialogue_buffer ──▶ 满一窗?──▶ process_window
(逐条进缓冲) (List[Dialogue]) │ │
否 │ 取窗口 + 上一窗条目(去重上下文)
│ ▼
finalize ──▶ _generate_memory_entries
(冲刷余量) │ LLM 抽取 → List[MemoryEntry]

VectorStore.add_entries
(embedding + 三视图落库)

└───────────────────────────────────────────────────┘

┌──────────────── 查询侧(阶段 ③)──────────────────┐

ask(question) ──▶ HybridRetriever.retrieve ──▶ AnswerGenerator.generate_answer ──▶ 答案
(意图规划 + 三视图召回) (从 context 合成简洁答复)

└───────────────────────────────────────────────────┘

怎么读:上半是写入,一条对话从缓冲到落库;下半是查询,一个问题从召回到答案。两条路径通过 VectorStore 这个共享存储相接——写入侧往里存,查询侧从里取。


3. 核心原理(逐个机制,由浅入深)

3.1 数据模型:MemoryEntry 的三层视图

先看存的是什么,后面的一切都围绕它转。

MemoryEntry(simplemem/core/models/memory_entry.py:13)是一个 Pydantic 模型,论文把它的索引写成 I(m_k) = {s_k, l_k, r_k}——同一条记忆,三种索引视角:

视图论文符号字段服务于哪种检索
语义层 Semantics_klossless_restatement(自包含事实句)稠密向量相似度(概念相近)
词汇层 Lexicall_kkeywords(核心关键词列表)BM25 式精确关键词匹配
符号层 Symbolicr_ktimestamp / location / persons / entities / topic元数据过滤(时间、人、地点等硬约束)

关键在于 lossless_restatement(:22-25),它的字段描述直接写明约束:"Self-contained fact with Φ_coref (no pronouns) and Φ_time (absolute timestamps)"——没有代词、时间全是绝对值。这就是"语义无损"落到数据结构上的样子。

为什么要三层?因为一个问题可能需要不同的召回方式:"他们几点见面"靠语义,"关于 product XYZ 的事"靠关键词,"11 月 15 日发生了什么"靠符号层的时间过滤。一条记忆同时挂在三种索引上,查询侧才能三路并召(细节见 第 2 章)。

Dialogue(:70-81)则是原始对话的模型,只有 dialogue_id / speaker / content / timestamp 四个字段,__str__ 会渲染成 [时间] speaker: content 喂给抽取 prompt。它是"输入",MemoryEntry 是"产出"。

3.2 阶段①:滑窗切分 + 隐式语义密度门控 Φ_gate

要解决的小问题: 对话是流式来的,不能一条一条单独抽取(会丢跨句上下文,比如"行,我准备材料"里的"材料"指什么),也不能攒到最后一次性抽(太长、超 token)。所以要滑窗

思路: 攒够一窗对话再送去抽取。窗口大小 WINDOW_SIZE 默认 40(simplemem/core/settings.py:26),窗口间保留 OVERLAP_SIZE(默认 2)条重叠,给下一窗留连续性上下文。

MemoryBuilder.__init__ 里把重叠换算成步长(memory_builder.py:42-45):

self.overlap_size = getattr(config, 'OVERLAP_SIZE', 0)
# step_size 是窗口每次前进多远;overlap 保留末尾 overlap_size 条给下一窗当上下文
self.step_size = max(1, self.window_size - self.overlap_size)

add_dialogue 把对话塞进 dialogue_buffer,一旦攒够一窗就触发 process_window(:58-66)。process_window 取头部一窗、但缓冲只前移 step_size(而非整窗),重叠因此自然产生(:141-142):

window = self.dialogue_buffer[:self.window_size] # 取整窗抽取
self.dialogue_buffer = self.dialogue_buffer[self.step_size:] # 只前移 step_size,尾部 overlap 留给下一窗

"隐式语义密度门控 Φ_gate" 是什么。 论文里 Φ_gate(W) → {m_k} 意为"过滤掉低信息密度的窗口/内容,只留有价值的记忆"。这里要诚实:代码里没有一个显式的打分/阈值函数去过滤窗口。门控是隐式的——它委托给了 LLM:抽取 prompt 要求"提取所有有价值的信息",低密度的寒暄自然不会被 LLM 抽成条目(_generate_memory_entries 的注释 :170-173 明确把自己标为 Φ_gate 的实现)。所以 Φ_gate 的"门"开在 prompt 语义里,不是一段可 grep 的阈值判断 (inferred:代码中无显式密度阈值,门控体现为 LLM 抽取行为)。

3.3 强制消解:Φ_coref(去代词)与 Φ_time(绝对时间)

要解决的小问题: 前面说记忆要"自包含"。可 LLM 默认会照抄"他明天来"。怎么强制它写成"Bob 于 2025-11-16 来"?

答案全在 prompt 里。 _build_extraction_prompt(memory_builder.py:229)把约束写死成硬要求(:246-256):

  • Force Disambiguation:"Absolutely PROHIBIT using pronouns (he, she, it, they, this, that) and relative time (yesterday, today, last week, tomorrow)"——这就是 Φ_coref + Φ_time。
  • Lossless Information:每条 lossless_restatement 必须是完整、独立、可理解的句子。
  • Complete Coverage:生成足够多的条目,确保对话里所有信息都被覆盖(这条同时服务阶段 ②的"信息全覆盖")。

prompt 里还塞了一个具体 few-shot 示例(:276-303),把"明天下午 2 点"演示成 2025-11-16T14:00:00,让模型照着做。抽取时 temperature=0.1(:213)压低随机性,追求稳定结构化输出。

原理演示(示意,非源码):

# 演示 Φ_coref / Φ_time 做的转换:把依赖上下文的表达变成绝对表达
raw = "[2025-11-15T14:30] Alice: Bob, 明天下午 2 点老地方见"
# LLM 按 prompt 约束改写为自包含事实:
entry = {
"lossless_restatement": "Alice 于 2025-11-15T14:30 建议在星巴克于 2025-11-16T14:00 与 Bob 会面",
"timestamp": "2025-11-16T14:00:00", # "明天下午2点" → 绝对时间(Φ_time)
"persons": ["Alice", "Bob"], # "Bob"、隐含的"Alice" 都显式化(Φ_coref)
"keywords": ["Alice", "Bob", "meeting"],
}
# 重点看:输出里再无"明天""老地方"这类离开上下文就失义的词

抽完后 _parse_llm_response(:308)把 LLM 返回的 JSON 数组逐个转成 MemoryEntry 对象(:325-333),缺字段用默认值兜底(keywords 空列表、timestamp None 等)。

3.4 阶段②:在线语义合成(写入即去重)

要解决的小问题: 滑窗有重叠(默认 2 条),相邻两窗会看到重复对话;同一件事也可能在对话里被反复提起。若不管,库里全是重复记忆。

思路——不是写后清理,而是写前避免。 抽取当前窗口时,把上一窗刚生成的条目当参考一起喂给 LLM,让它"看到已经记过什么,别再重复记"。

_generate_memory_entries 里构造这段 context(memory_builder.py:180-184):

context = ""
if self.previous_entries:
context = "\n[Previous Window Memory Entries (for reference to avoid duplication)]\n"
for entry in self.previous_entries[:3]: # 只放前 3 条,控制 prompt 长度
context += f"- {entry.lossless_restatement}\n"

这段 context 会拼进 prompt 的 {context} 占位(:241)。每处理完一窗,process_window 就把这窗的产出存进 self.previous_entries 作下一窗的上下文(:152):

self.previous_entries = entries # 存为下一窗的去重上下文

这就是论文说的 §3.2"Online Semantic Synthesis / intra-session consolidation":去重发生在会话内、写入的当下,而不是事后跑一个合并任务。注意它是启发式的——只喂前 3 条、且靠 LLM 自觉,不是精确的相似度去重;跨窗很远的重复它未必能防住 (inferred)。

3.5 阶段③入口:意图感知检索(本章只到接缝)

写入侧讲完,查询侧从 ask 起步(system.py:146-170):

def ask(self, question: str) -> str:
# 阶段 3:意图感知检索规划
contexts = self.hybrid_retriever.retrieve(question) # 三视图召回
answer = self.answer_generator.generate_answer(question, contexts) # 从 context 合成答案
return answer

retrieve(hybrid_retriever.py:58)内部会做意图规划、多查询、三视图并召、反思——这些是 第 2 章 的内容,本章不展开

召回回来的是一批 MemoryEntry,交给 AnswerGenerator.generate_answer(answer_generator.py:22)。它做三件事:空则直接返回 "No relevant information found"(:33-34);把每条记忆的各视图字段格式化成可读 context(_format_contexts,:85);再用一个要求"先推理、后给极简短语答案、日期统一 'DD Month YYYY'"的 prompt(_build_answer_prompt,:113-153)让 LLM 抽出最终答案,解析 JSON 取 answer 字段(:70-72)。整个生成也有 3 次重试兜底(:56-83)。

至此,一条对话从进入缓冲到变成答案的完整闭环:

add_dialogue → dialogue_buffer → process_window → _generate_memory_entries
→ (LLM 抽取 + Φ_coref/Φ_time + 去重上下文) → VectorStore.add_entries
─────────────────────────────────────────────────────────────
ask → HybridRetriever.retrieve → AnswerGenerator.generate_answer → 答案

4. 深入实现:并行建库与它的坑

单条 add_dialogue 是顺序处理的,但批量灌历史数据时(比如导入几百轮对话),顺序调 LLM 太慢。于是有并行建库这一支,也是本章最容易踩坑的地方。

4.1 何时走并行

add_dialogues(memory_builder.py:68)按批量大小选路(:72-83):

if self.enable_parallel_processing and len(dialogues) > self.window_size * 2:
self.add_dialogues_parallel(dialogues) # 大批量:并行
else:
for dialogue in dialogues: # 小批量:顺序,攒够整窗就 process
self.add_dialogue(dialogue, auto_process=False)
if auto_process:
while len(self.dialogue_buffer) >= self.window_size:
self.process_window()

门槛是"批量 > 两倍窗口大小"才并行,小批量走顺序省得开线程。

4.2 并行的切窗与落库

add_dialogues_parallel(:85)先把整批塞进缓冲,再按 step_size 切成一组窗口(:99-109),不满一窗的尾巴单独作一个小批,然后清空缓冲、把所有窗口丢给线程池:

pos = 0
while pos + self.window_size <= len(self.dialogue_buffer):
window = self.dialogue_buffer[pos:pos + self.window_size]
windows_to_process.append(window)
pos += self.step_size # 按 step_size 前进,保留 overlap
remaining = self.dialogue_buffer[pos:] # 剩余不满一窗的,单独成批
if remaining:
windows_to_process.append(remaining)
self.dialogue_buffer = [] # 全部交出去,清空缓冲

_process_windows_parallel(:338)用 ThreadPoolExecutor(默认 MAX_PARALLEL_WORKERS,settings 里是 16——settings.py:34)并发跑每个窗口的抽取,as_completed 收集结果,最后一次性批量落库(:363-367)。

4.3 三个坑

坑一:并行模式下去重上下文失真。 顺序模式里 previous_entries 是"严格的上一窗"。但并行模式所有 worker 共享同一份 previous_entries(:388,worker 里的 context 构造),各窗看到的是同样的旧上下文,而不是各自真正的前一窗——注释也承认这点(:386"shared across all workers")。跑完后 previous_entries 被更新成"最后 10 条"(:371),语义已不同于顺序模式的"上一窗全部"。结论:并行更快,但去重不如顺序精确。

坑二:异常回退无法天真地重叠重放。 并行途中若抛异常,except 分支要回退到顺序处理(:118-130)。难点在于缓冲可能已在 :109 被清空,重叠窗口也没法简单"再堆回去"。代码用 pre_existing 快照 + 判断缓冲是否为空来还原状态:

if not self.dialogue_buffer: # 缓冲已被清空(异常发生在清空之后)
self.dialogue_buffer = pre_existing + list(dialogues) # 还原:原有的 + 本次要处理的
# process_window() 用 step_size,重叠在这里被正确处理
while len(self.dialogue_buffer) >= self.window_size:
self.process_window()

注意它只在缓冲为空时才还原(:126);若异常发生在清空之前,缓冲里已是 pre_existing + dialogues,原样保留即可。这是为了避免"双倍塞入"。

坑三:finalize() 与并行的关系。 finalize()(system.py:139-144)只调 process_remaining(),顺序处理缓冲里剩下的对话。在并行路径里,余量在 :106-108 已作为 remaining 批一起处理掉了,所以并行模式下 finalize() 通常无事可做——它是顺序模式的安全网,把最后不满一窗的对话冲刷掉(process_remaining,memory_builder.py:157-168)。忘了调 finalize(),顺序模式下尾部不满一窗的对话就永远留在缓冲里、进不了库。


5. 巧妙之处(可借鉴的技术)

  • "压缩 = 去上下文依赖"而非"变短"。 把每条记忆重写成自包含事实句(lossless_restatement),从根上避免了"检索到一条离开原文就看不懂的碎片"。锚点:memory_entry.py:22-25
  • 去重前置而非后清理。 把上一窗产出喂给下一窗当参考,让 LLM 在生成时就避开重复,省掉一个昂贵的事后相似度合并阶段。锚点:memory_builder.py:180-184:152
  • 约束写进 prompt 而非后处理。 Φ_coref / Φ_time 不是用正则去清洗代词和相对时间,而是把"禁止代词、时间必须绝对"作为硬要求写进抽取 prompt,并配 few-shot 示例。锚点:memory_builder.py:246-303
  • 一条记忆三视图。 同一条 MemoryEntry 同时挂语义/词汇/符号三种索引,让查询侧能按问题类型选择召回通道。锚点:memory_entry.py:17:22-53
  • 步长即重叠。step_size = window_size - overlap_size 一个减法就实现了滑窗重叠,取窗取整窗、前移只走 step,重叠自然产生。锚点:memory_builder.py:45:141-142

6. 边界与局限(诚实)

  • Φ_gate 没有显式阈值。 "隐式语义密度门控"完全委托给 LLM 的抽取判断,代码里没有可调的密度阈值或过滤函数——低价值内容能否被滤掉,取决于模型和 prompt。
  • 去重是启发式、非精确。 只喂前 3 条 previous_entries(顺序)/ 最后 10 条(并行)当参考,靠 LLM 自觉。跨越多窗的远距离重复、或 LLM 判断失误,都可能漏去重。
  • 并行牺牲去重精度。 见 §4.3 坑一:worker 共享同一份旧上下文,去重效果弱于顺序模式。追求记忆干净时应关掉并行。
  • 强依赖 LLM 输出合规。 抽取和答案生成都要求 LLM 返回合法 JSON,虽有 3 次重试(memory_builder.py:202-227answer_generator.py:55-83),但连续失败会返回空条目 / "Failed to generate answer"——这一窗的信息就丢了。
  • 忘调 finalize() 会漏尾部对话。 顺序模式下不满一窗且未 finalize 的对话永远进不了库(§4.3 坑三)。

7. 横向对比

同 shelf 的其它记忆/上下文方案,与 SimpleMem 文本支的取舍差异:

  • 对"原始 vs 改写"的取舍: SimpleMem 存的是 LLM 改写后的自包含事实,不存原文;许多向量记忆方案直接切原文块存,靠检索时的上下文拼接补义。SimpleMem 用"写入时多花一次 LLM 抽取"换"检索时无歧义"。
  • 对"去重时机"的取舍: SimpleMem 写入即去重(§3.4);另有方案(如本库 EvolveMem 支)把整合放到后台演化阶段做。前者省事后合并、但精度弱;后者更彻底、但更重。

同库其它章:总览见 index;查询侧见 混合检索;自进化检索见 EvolveMem;多模态与跨会话见 Omni 与 Cross


8. 代码地图(导航索引)

主题文件路径符号名
系统门面 / 三阶段编排simplemem/text/system.py:17SimpleMemSystem
加单条对话 / 生成 dialogue_idsimplemem/text/system.py:112add_dialogue
收尾冲刷缓冲simplemem/text/system.py:139finalizeprocess_remaining
提问入口(阶段③)simplemem/text/system.py:146ask
记忆构建器simplemem/core/memory_builder.py:21MemoryBuilder
滑窗步长/重叠simplemem/core/memory_builder.py:45step_size / overlap_size
处理一窗(切窗+抽取+存)simplemem/core/memory_builder.py:132process_window
LLM 抽取(Φ_gate)simplemem/core/memory_builder.py:170_generate_memory_entries
抽取 prompt(Φ_coref/Φ_time/去重)simplemem/core/memory_builder.py:229_build_extraction_prompt
并行建库入口simplemem/core/memory_builder.py:85add_dialogues_parallel
并行切窗+线程池落库simplemem/core/memory_builder.py:338_process_windows_parallel
记忆单元(三视图)simplemem/core/models/memory_entry.py:13MemoryEntry
原始对话模型simplemem/core/models/memory_entry.py:70Dialogue
答案合成simplemem/core/answer_generator.py:22generate_answer
写库接缝(→ 第 2 章)simplemem/core/database/vector_store.py:121add_entries
召回接缝(→ 第 2 章)simplemem/core/hybrid_retriever.py:58retrieve
默认配置(窗口/topK/并行数)simplemem/core/settings.py:12_DEFAULTS