系列导航:这是「Agent Memory 系列」第 07 篇,也是 MemPalace 三部曲的中间一篇 。
第 05 篇讲了 MemPalace 的宫殿隐喻与整体架构,第 06 篇讲了从原始对话到 drawer 的挖掘流水线,
这一篇进入它最有"工程味"的三个子系统:AAAK 压缩方言 + 时序知识图谱 + ChromaDB 语义检索 。
第 08 篇将收尾于 MCP 服务、CLI 与端到端实战。
文章目录
- [1. 🎯 引言:为什么这三件套要放在同一篇](#1. 🎯 引言:为什么这三件套要放在同一篇)
- [2. 🔤 Part A --- AAAK 方言:给任何 LLM 直接读的压缩文本](#2. 🔤 Part A — AAAK 方言:给任何 LLM 直接读的压缩文本)
- [2.1 AAAK 是什么,不是什么](#2.1 AAAK 是什么,不是什么)
- [2.2 设计原则:lossy abbreviation、entity codes、structural markers](#2.2 设计原则:lossy abbreviation、entity codes、structural markers)
- [2.3 EntityRegistry:让
Riley不再被当作副词ever](#2.3 EntityRegistry:让 Riley 不再被当作副词 ever) - [2.4 编码流水线:从 plain text 到一行 AAAK](#2.4 编码流水线:从 plain text 到一行 AAAK)
- [2.5 解码、Layer1 唤醒文件与
compression_stats](#2.5 解码、Layer1 唤醒文件与 compression_stats) - [2.6 诚实的边界:84.2% vs 96.6% 与 4 月 7 日的澄清](#2.6 诚实的边界:84.2% vs 96.6% 与 4 月 7 日的澄清)
- [3. 🧬 Part B --- 时序知识图谱:把 Neo4j 塞进一个 SQLite 文件](#3. 🧬 Part B — 时序知识图谱:把 Neo4j 塞进一个 SQLite 文件)
- [3.1 数据模型:五元组 (s, p, o, valid_from, valid_to)](#3.1 数据模型:五元组 (s, p, o, valid_from, valid_to))
- [3.2 SQLite schema 与索引](#3.2 SQLite schema 与索引)
- [3.3
add_triple/invalidate/query_entity/as_of](#3.3 add_triple / invalidate / query_entity / as_of) - [3.4 时序有效性窗口的数学描述](#3.4 时序有效性窗口的数学描述)
- [3.5
palace_graph.py:房间级导航图与 tunnels](#3.5 palace_graph.py:房间级导航图与 tunnels) - [3.6 对比:MemPalace KG vs Zep Graphiti vs Neo4j](#3.6 对比:MemPalace KG vs Zep Graphiti vs Neo4j)
- [4. 🔍 Part C --- 语义检索:152 行的
searcher.py与 where 过滤](#4. 🔍 Part C — 语义检索:152 行的 searcher.py 与 where 过滤)- [4.1
PersistentClient+mempalace_drawerscollection](#4.1 PersistentClient + mempalace_drawers collection) - [4.2 默认 embedding:sentence-transformers / ONNX 与 ARM64 坑](#4.2 默认 embedding:sentence-transformers / ONNX 与 ARM64 坑)
- [4.3 where filter:wing/room 元数据过滤的工程价值](#4.3 where filter:wing/room 元数据过滤的工程价值)
- [4.4
searchvssearch_memories:CLI 打印 vs MCP 数据](#4.4 search vs search_memories:CLI 打印 vs MCP 数据) - [4.5 对比:ChromaDB 检索 vs OpenClaw Hybrid Search](#4.5 对比:ChromaDB 检索 vs OpenClaw Hybrid Search)
- [4.1
- [5. 📚 小结与下篇预告](#5. 📚 小结与下篇预告)
- [6. 📖 参考文献](#6. 📖 参考文献)

1. 🎯 引言:为什么这三件套要放在同一篇
MemPalace 仓库里有接近三十个 Python 文件,但如果你问作者 Milla 哪几段代码"最能代表 MemPalace 的性格",答案大概率会是这三份:
mempalace/dialect.py 1075 行 AAAK 方言,最大单文件
mempalace/entity_registry.py 639 行 谁是人,谁是副词
mempalace/knowledge_graph.py 387 行 时序知识图谱 (SQLite)
mempalace/palace_graph.py 227 行 房间级导航图 (ChromaDB 上层)
mempalace/searcher.py 152 行 ChromaDB 语义检索入口
这三者看起来风马牛不相及:一个是文本压缩,一个是符号图,一个是向量检索。但它们在 MemPalace 内部被串在了同一条"从原始文本到可被 LLM 使用的记忆"的管道上:
┌─────────────────────────────────────────────────────┐
│ 原始对话 / 项目文件 (verbatim) │
└───────────────────────┬─────────────────────────────┘
│ convo_miner (见第 06 篇)
▼
┌─────────────────────────────────────────────────────┐
│ drawers (closet / wing / room / hall) │
│ 存在 ChromaDB: mempalace_drawers │
└──────┬──────────────────┬──────────────────┬────────┘
│ │ │
(A) 压缩 │ (B) 抽取 │ (C) 检索 │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ dialect.py │ │ knowledge_graph │ │ searcher.py │
│ AAAK 方言 │ │ SQLite triples │ │ vec + where │
│ 用于 Layer1 / │ │ (s,p,o, │ │ filter │
│ prompt 前缀 │ │ valid_from/to) │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
└──────────────────┴──────────────────┘
│
▼
LLM prompt context
Why 要放一起讲? 因为这三块分别回答了一个记忆系统的三个根本问题:
- 怎么把"长"变"短"而不丢可读性? ------ AAAK 方言。
- 怎么把"散乱的句子"变成"可以按时间切片的事实"? ------ 时序知识图谱。
- 怎么让"一个问句"精确找回相关 drawer? ------ ChromaDB 语义检索 + where filter。
每一块单独看都不是 MemPalace 的发明,但把它们组合在本地、用 SQLite + 一个 ChromaDB 文件、零云依赖地跑起来,是 MemPalace 的独特取舍。本篇会逐块拆源码,也会诚实讨论作者自己在 README 里写过的"我们搞错了什么"。
2. 🔤 Part A --- AAAK 方言:给任何 LLM 直接读的压缩文本
2.1 AAAK 是什么,不是什么
打开 mempalace/dialect.py,文件头注释第一段就已经定了调子:
python
"""
AAAK Dialect -- Structured Symbolic Summary Format
====================================================
A lossy summarization format that extracts entities, topics, key sentences,
emotions, and flags from plain text into a compact structured representation.
Any LLM reads it natively --- no decoder required.
Works with: Claude, ChatGPT, Gemini, Llama, Mistral -- any model that reads text.
NOTE: AAAK is NOT lossless compression. The original text cannot be reconstructed
from AAAK output. It is a structured summary layer (closets) that points to the
original verbatim content (drawers). The 96.6% benchmark score is from raw mode,
not AAAK mode.
"""
三行核心信息:
- AAAK 是一种"结构化符号摘要" ,不是 gzip,不是 huffman,也不是 LLMLingua 这种 token 层 prompt 压缩。它是人类/LLM 都能直接读的纯文本方言,没有解码器。
- 它是 lossy 的,原文不能从 AAAK 反推。这一点作者在文件最上面就写死,避免后续误解。
- 96.6% 的 LongMemEval 分数是 raw mode 拿到的,不是 AAAK mode。 这句话是 2026 年 4 月 7 日那份公开勘误之后作者回头加进源码的。
一条完整的 AAAK 行长这样(取自 compress() 的输出结构):
wing_code|chromadb_setup|2026-03-12|setup_notes
0:MAX+ALC|chromadb_persistent_client|"We switched to persistent client because session mode lost data"|determ+convict|DECISION+TECHNICAL
两行分别是 header 行 和内容行 ,用 | 做列分隔。格式在文件头的 docstring 里有规范定义:
FORMAT:
Header: FILE_NUM|PRIMARY_ENTITY|DATE|TITLE
Zettel: ZID:ENTITIES|topic_keywords|"key_quote"|WEIGHT|EMOTIONS|FLAGS
Tunnel: T:ZID<->ZID|label
Arc: ARC:emotion->emotion->emotion
四种行类型共用一个很小的语法,LLM 一眼就能读出结构------这是 AAAK 最大的卖点:你不需要训练模型理解它,也不需要写 parser 喂给模型,直接把这些文本塞进 prompt,GPT-4 / Claude / Llama 都能在 zero-shot 下用得起来。
2.2 设计原则:lossy abbreviation、entity codes、structural markers
拆 AAAK 的编码流程之前,先看几个"常量表",它们定义了 AAAK 的"词汇表"。
Emotion codes (节选自 dialect.py L53-94):
python
EMOTION_CODES = {
"vulnerability": "vul",
"vulnerable": "vul",
"joy": "joy",
"joyful": "joy",
"fear": "fear",
"trust": "trust",
"grief": "grief",
"wonder": "wonder",
"rage": "rage",
...
"self_doubt": "doubt",
"anxiety": "anx",
"exhaustion": "exhaust",
"conviction": "convict",
"quiet_passion": "passion",
"determination": "determ",
}
这 30 多个三到五字符的 code 构成一个固定情绪表。编码时 encode_emotions() 会把情绪词映射成 code、去重、最多取前 3 个,用 + 连接:
python
def encode_emotions(self, emotions: List[str]) -> str:
"""Convert emotion list to compact codes."""
codes = []
for e in emotions:
code = EMOTION_CODES.get(e, e[:4])
if code not in codes:
codes.append(code)
return "+".join(codes[:3])
注意 EMOTION_CODES.get(e, e[:4]) 这一行:对于字典里没有的情绪词,它会 fallback 成"前 4 个字符"。这是 AAAK 全流程都在用的一个兜底策略------"实在没有正式编码,就用一个截断形式"。它保证了编码函数从不会因为未知输入而崩溃,但代价是码表本身具有一定的"不可反向"性。
Flag signals(节选 L123-158):
python
_FLAG_SIGNALS = {
"decided": "DECISION",
"chose": "DECISION",
"switched": "DECISION",
"because": "DECISION",
"founded": "ORIGIN",
"created": "ORIGIN",
"started": "ORIGIN",
"turning point": "PIVOT",
"realized": "PIVOT",
"breakthrough": "PIVOT",
"api": "TECHNICAL",
"database": "TECHNICAL",
"architecture": "TECHNICAL",
...
}
AAAK 把"这条记忆重要吗"这个判断做成了关键词字典 ------看到 decided 就打 DECISION 标签,看到 architecture 就打 TECHNICAL。非常 dumb,也非常可预测。这里有一条显式的设计哲学:AAAK 拒绝在编码阶段引入 LLM。
Why 不用 LLM 打情绪/标签?
因为 AAAK 的目标是给 LLM 做上下文前缀------如果连构造这个前缀都要调一次 LLM,那 MemPalace 的本地零成本故事就彻底破产了。用正则表和关键词表换来的是:
- 完全确定性、可 diff、可单元测试;
- 没有 API key 依赖,本地 CPU 毫秒级完成;
- 用户可以自己加
_FLAG_SIGNALS,这是一份用户可控的"偏好表"。
Stop words L161-295 是另一张表,大约 130 多个常见虚词,用于从话题抽取里过滤掉 the / is / because / thing / really / want ...。这张表等会儿会在 _extract_topics() 里用到。
2.3 EntityRegistry:让 Riley 不再被当作副词 ever
AAAK 里最有意思的技术细节不是压缩本身,而是它怎么决定一个大写的词是人名。dialect 自己只做一个很粗的实体识别:
python
def _detect_entities_in_text(self, text: str) -> List[str]:
"""Find known entities in text, or detect capitalized names."""
found = []
# Check known entities
for name, code in self.entity_codes.items():
if not name.islower() and name.lower() in text.lower():
if code not in found:
found.append(code)
if found:
return found
# Fallback: find capitalized words that look like names
words = text.split()
for i, w in enumerate(words):
clean = re.sub(r"[^a-zA-Z]", "", w)
if (
len(clean) >= 2
and clean[0].isupper()
and clean[1:].islower()
and i > 0
and clean.lower() not in _STOP_WORDS
):
code = clean[:3].upper()
...
这段代码有一个严重的现实问题:英文里有一堆"看起来像名字的常用词"。Will、Grace、Mark、April、Hope、Joy、Hunter 既是人名也是动词/名词/月份。在 MemPalace 的对话语料里,用户可能真的有个叫 Riley 的女儿,但 "I don't ever want to forget" 里的 ever 不能被识别成一个叫 Ever 的实体。
解决方案不是放在 dialect.py 里,而是一整个独立的 entity_registry.py,它是 MemPalace "第二个最有个性"的文件。先看它自己写的 docstring:
python
"""
entity_registry.py --- Persistent personal entity registry for MemPalace.
Knows the difference between Riley (a person) and ever (an adverb).
Built from three sources, in priority order:
1. Onboarding --- what the user explicitly told us
2. Learned --- what we inferred from session history with high confidence
3. Researched --- what we looked up via Wikipedia for unknown words
"""
一个具名实体登记簿,三个来源按优先级排:
┌──────────────────────────────────────┐
│ ~/.mempalace/entity_registry.json │
└───────────────┬──────────────────────┘
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
onboarding learned wiki_cache
(confidence=1) (>=0.75) (Wikipedia REST API)
│ │ │
└──── people ─────┴── ambiguous_flags + aliases ──┘
核心机制 1:AMBIGUOUS 词表
python
COMMON_ENGLISH_WORDS = {
"ever", "grace", "will", "bill", "mark", "april", "may", "june",
"joy", "hope", "faith", "chance", "chase", "hunter", "dash",
"flash", "star", "sky", "river", "brook", "lane", "art", "clay",
"nat", "max", "rex", "ray", "jay", "rose", "violet", "lily",
"ivy", "ash", "reed", "sage",
"monday", "tuesday", ..., "january", "february", ...
}
任何 onboarding/learned 注册进来的人名,只要它的 lowercase 形式落在这张表里,就会被 push 进 ambiguous_flags。
核心机制 2:上下文消歧模式
python
PERSON_CONTEXT_PATTERNS = [
r"\b{name}\s+said\b",
r"\b{name}\s+told\b",
r"\bwith\s+{name}\b",
r"\bsaw\s+{name}\b",
r"\b{name}(?:'s|s')\b", # Riley's
r"^{name}[:\s]", # dialogue Riley: ...
r"\bmy\s+(?:son|daughter|kid|child|brother|sister|friend|"
r"partner|colleague|coworker)\s+{name}\b",
]
CONCEPT_CONTEXT_PATTERNS = [
r"\bhave\s+you\s+{name}\b", # "have you ever"
r"\bif\s+you\s+{name}\b", # "if you ever"
r"\b{name}\s+since\b", # "ever since"
r"\bnot\s+{name}\b", # "not ever"
r"\bwould\s+{name}\b", # "would ever"
r"(?:the\s+)?{name}\s+(?:of|in|at|for|to)\b",
]
当 lookup("Ever", context="I don't ever want to forget") 被调用:
python
def _disambiguate(self, word, context, person_info):
name_lower = word.lower()
ctx_lower = context.lower()
person_score = 0
for pat in PERSON_CONTEXT_PATTERNS:
if re.search(pat.format(name=re.escape(name_lower)), ctx_lower):
person_score += 1
concept_score = 0
for pat in CONCEPT_CONTEXT_PATTERNS:
if re.search(pat.format(name=re.escape(name_lower)), ctx_lower):
concept_score += 1
if person_score > concept_score:
return {"type": "person", "confidence": min(0.95, 0.7 + person_score*0.1), ...}
elif concept_score > person_score:
return {"type": "concept", "confidence": min(0.90, 0.7 + concept_score*0.1), ...}
return None # truly ambiguous → fall through to person
一个简单但很实用的 规则计分器 。形式化地写,设 P P P 为 person 模式集合、 C C C 为 concept 模式集合,一段上下文 x x x 的得分为:
s p e r s o n ( x ) = ∑ p ∈ P 1 [ p matches x ] , s c o n c e p t ( x ) = ∑ c ∈ C 1 [ c matches x ] s_{person}(x) = \sum_{p \in P} \mathbb{1}[p\text{ matches }x], \quad s_{concept}(x) = \sum_{c \in C} \mathbb{1}[c\text{ matches }x] sperson(x)=p∈P∑1[p matches x],sconcept(x)=c∈C∑1[c matches x]
最终类型判定:
type ( x ) = { person , s p e r s o n ( x ) > s c o n c e p t ( x ) concept , s c o n c e p t ( x ) > s p e r s o n ( x ) person (fallback) , s p e r s o n ( x ) = s c o n c e p t ( x ) \text{type}(x) = \begin{cases} \text{person}, & s_{person}(x) > s_{concept}(x) \\ \text{concept}, & s_{concept}(x) > s_{person}(x) \\ \text{person (fallback)}, & s_{person}(x) = s_{concept}(x) \end{cases} type(x)=⎩ ⎨ ⎧person,concept,person (fallback),sperson(x)>sconcept(x)sconcept(x)>sperson(x)sperson(x)=sconcept(x)
置信度则写成一个软 cap:
conf ( x ) = min ( 0.95 , 0.7 + 0.1 ⋅ s p e r s o n ( x ) ) \text{conf}(x) = \min\left(0.95,\ 0.7 + 0.1 \cdot s_{person}(x)\right) conf(x)=min(0.95, 0.7+0.1⋅sperson(x))
核心机制 3:Wikipedia 兜底
对于完全未知的大写词(不在 onboarding、不在 learned、不在 stop words),research() 会去撞一下 Wikipedia REST:
python
def _wikipedia_lookup(word: str) -> dict:
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(word)}"
req = urllib.request.Request(url, headers={"User-Agent": "MemPalace/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
extract = data.get("extract", "").lower()
if any(phrase in extract for phrase in NAME_INDICATOR_PHRASES):
return {"inferred_type": "person", "confidence": 0.90, ...}
if any(phrase in extract for phrase in PLACE_INDICATOR_PHRASES):
return {"inferred_type": "place", "confidence": 0.80, ...}
...
NAME_INDICATOR_PHRASES 包含 "given name", "forename", "masculine name", "irish name", "legendary welsh" 等等。如果 Wikipedia summary 里出现这些短语,就把它分类成 person,0.9 置信度。
还有一个"没有人会想到的兜底"------404 本身也是信号:
python
except urllib.error.HTTPError as e:
if e.code == 404:
return {
"inferred_type": "person",
"confidence": 0.70,
"note": "not found in Wikipedia --- likely a proper noun or unusual name",
}
如果一个大写的词连 Wikipedia 都查不到,它更有可能是某个家人或朋友的昵称,而不是某个抽象概念。这是作者对"真实世界里你会拿什么跟 AI 聊"的一个很贴身的观察。
Why 要做成三层而不是一层?
直接扔给 LLM 做 NER 当然更准,但 MemPalace 想要:
- 离线:不联网也要能跑 onboarding + learned 这两层;
- 便宜:每一次 mine、每一条 drawer 都会被过一遍,LLM 调用会爆钱;
- 可控:用户对"Max 是我儿子不是 Twitter 的 CEO"有最终解释权------onboarding 优先级永远最高。
三层的结构让你可以只在第一次遇到一个陌生大写词时 去撞 Wikipedia,后续命中 wiki_cache 就是 O(1)。
2.4 编码流水线:从 plain text 到一行 AAAK
有了 EntityRegistry 和那三张表,AAAK 的 plain text 路径就非常直接了。核心在 Dialect.compress():
python
def compress(self, text: str, metadata: dict = None) -> str:
metadata = metadata or {}
# Detect components
entities = self._detect_entities_in_text(text)
entity_str = "+".join(entities[:3]) if entities else "???"
topics = self._extract_topics(text)
topic_str = "_".join(topics[:3]) if topics else "misc"
quote = self._extract_key_sentence(text)
quote_part = f'"{quote}"' if quote else ""
emotions = self._detect_emotions(text)
emotion_str = "+".join(emotions) if emotions else ""
flags = self._detect_flags(text)
flag_str = "+".join(flags) if flags else ""
# Build source header if metadata available
source = metadata.get("source_file", "")
wing = metadata.get("wing", "")
room = metadata.get("room", "")
date = metadata.get("date", "")
lines = []
if source or wing:
header_parts = [wing or "?", room or "?", date or "?",
Path(source).stem if source else "?"]
lines.append("|".join(header_parts))
parts = [f"0:{entity_str}", topic_str]
if quote_part: parts.append(quote_part)
if emotion_str: parts.append(emotion_str)
if flag_str: parts.append(flag_str)
lines.append("|".join(parts))
return "\n".join(lines)
可视化这条流水线:
compress(text, metadata)
│
┌─────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
_detect_entities_in_text _extract_topics _extract_key_sentence
├─ EntityRegistry match ├─ 分词 (regex) ├─ 切句
├─ 大写+非句首 ├─ 去 STOP_WORDS ├─ decision_words 加分
└─ 取前 3 个 ├─ 提升 CamelCase/ ├─ 短句加分 (<80)
│ Proper noun ├─ 长句减分 (>150)
│ └─ top 3 └─ 截断到 55 字符
▼ │ │
ENT1+ENT2+ENT3 topic1_topic2 "..key quote.."
│ │ │
└─────────────┬───────────┴───────────────────┘
│ ┌──────────────────────┐
│ │ _detect_emotions │ (keyword → code)
│ │ _detect_flags │ (keyword → FLAG)
│ └──────────┬───────────┘
▼ ▼
wing|room|date|filename_stem
0:ENT1+ENT2|topic1_topic2|"quote"|emo+code|FLAG+FLAG
举个具体例子。输入:
text
We decided to switch to ChromaDB's persistent client because the
session mode was losing data between runs. Max was frustrated; I'm
worried we'll break the benchmark reproducibility.
假设 registry 里已知 Max → MAX,运行 dialect.compress(text, {"wing":"wing_code","room":"chromadb-setup","date":"2026-03-12","source_file":"notes.md"}),大致会得到:
wing_code|chromadb-setup|2026-03-12|notes
0:MAX|chromadb_persistent_client|"We decided to switch to ChromaDB..."|determ+frust+anx|DECISION+TECHNICAL
几个值得一提的细节:
_extract_topics()会把ChromaDB当 proper noun 加分(CamelCase),所以chromadb会排到前面;_extract_key_sentence()对含decided / because / switched的句子每个词 +2 分,所以那条 "We decided to switch ..." 会胜出;_detect_emotions()命中decided → determ、frustrated → frust、worried → anx;_detect_flags()命中decided → DECISION、database → TECHNICAL(虽然这里是 ChromaDB,但走的是switched → DECISION与database关键字)。
整个过程纯正则和字典,在我本地 M2 上跑一条中等长度文本 < 2ms。
2.5 解码、Layer1 唤醒文件与 compression_stats
AAAK 也有一个形式意义上的解码器,但它不恢复原文,只把一行 AAAK 拆回字段:
python
def decode(self, dialect_text: str) -> dict:
"""Parse an AAAK Dialect string back into a readable summary."""
lines = dialect_text.strip().split("\n")
result = {"header": {}, "arc": "", "zettels": [], "tunnels": []}
for line in lines:
if line.startswith("ARC:"):
result["arc"] = line[4:]
elif line.startswith("T:"):
result["tunnels"].append(line)
elif "|" in line and ":" in line.split("|")[0]:
result["zettels"].append(line)
elif "|" in line:
parts = line.split("|")
result["header"] = {
"file": parts[0] if len(parts) > 0 else "",
"entities": parts[1] if len(parts) > 1 else "",
"date": parts[2] if len(parts) > 2 else "",
"title": parts[3] if len(parts) > 3 else "",
}
return result
这不是解压------原文永远找不回来,你能找回的只是"行的结构"。AAAK 的真正"解码器"是 LLM:你把这段文本塞进 prompt,模型自己会根据上下文推断 ENT1 是谁、determ+frust 是啥情绪。
Layer1 唤醒文件 :AAAK 还有一个更高阶的用法,叫 generate_layer1()。它会扫所有 zettel 文件、按 emotional_weight ≥ 0.85 或者 ORIGIN/CORE/GENESIS 标记过滤出最"本质"的记忆,然后按日期分组输出一个 LAYER1.aaak 文件:
python
def generate_layer1(self, zettel_dir, output_path=None, identity_sections=None,
weight_threshold: float = 0.85) -> str:
essential = []
for fname in sorted(os.listdir(zettel_dir)):
...
for z in data.get("zettels", []):
weight = z.get("emotional_weight", 0)
is_origin = z.get("origin_moment", False)
flags = self.get_flags(z)
has_key_flag = any(f in flags for f in ["ORIGIN","CORE","GENESIS"])
if weight >= weight_threshold or is_origin or has_key_flag:
essential.append((z, file_num, source_date))
...
输出形如:
## LAYER 1 -- ESSENTIAL STORY
## Auto-generated from zettel files. Updated 2026-04-09.
=IDENTITY=
I value reproducible benchmarks and open source.
=MOMENTS[2025-10-01]=
MAX|loves_chess|"I want to play every day"|0.9|ORIGIN
ALC+MAX|chess_club|"turning point"|0.85|PIVOT
=TUNNELS=
chess club decision → found at 2025-11 competition
作者把它称作"唤醒文件"------你启动任何 LLM 会话时,先把这 ~170 token 塞进 system prompt,模型就自带了关于"你是谁、你在意什么、发生过什么"的最小必要上下文。这是 MemPalace 对 "persistent identity in stateless LLMs" 这个问题的回答。
compression_stats :作者在 4 月 7 日那次勘误之后特意重写了 count_tokens():
python
@staticmethod
def count_tokens(text: str) -> int:
"""Estimate token count using word-based heuristic (~1.3 tokens per word).
This is an approximation. For accurate counts, use a real tokenizer
like tiktoken. The old len(text)//3 heuristic was wildly inaccurate
and made AAAK compression ratios look much better than reality.
"""
words = text.split()
return max(1, int(len(words) * 1.3))
以及最重要的 ------ compression_stats() 里那段诚实说明:
python
def compression_stats(self, original_text: str, compressed: str) -> dict:
"""Get size comparison stats for a text->AAAK conversion.
NOTE: AAAK is lossy summarization, not compression. The "ratio"
reflects how much shorter the summary is, not a compression ratio
in the traditional sense --- information is lost.
"""
...
return {
...
"note": "Estimates only. Use tiktoken for accurate counts. AAAK is lossy.",
}
这一段注释值得在"技术诚实度"这个维度单独加分。大多数开源项目会把 ratio 字段当作功勋章一直挂着,MemPalace 专门写了一段话告诉你"这不是你以为的 compression ratio"。
2.6 诚实的边界:84.2% vs 96.6% 与 4 月 7 日的澄清
READM 里那段"A note from Milla & Ben --- April 7 2026"是 MemPalace 的关键文化标志,也是本文必须照抄过来的一段:
What we got wrong:
AAAK does not save tokens at small scales. The old heuristic
len(text)//3was wildly inaccurate. Real counts via OpenAI's tokenizer: the English example is 66 tokens, the AAAK example is 73. AAAK is designed for repeated entities at scale, and the README example was a bad demonstration."30x lossless compression" was overstated. AAAK is a lossy abbreviation system. Independent benchmarks show AAAK mode scores 84.2% R@5 vs raw mode's 96.6% on LongMemEval --- a 12.4 point regression.
The 96.6% headline number is from RAW mode, not AAAK.
翻译成技术判断:
-
AAAK 在小规模文本上不省 token 。它的收益来自"重复实体"的编码复用。假设有 N 条 drawer,都涉及同一组人物 { P 1 , P 2 , ... , P k } \{P_1, P_2, \dots, P_k\} {P1,P2,...,Pk},原始写法每次都要出现
Maxwell、Alice Chen、Riley Johnson,AAAK 一次建立MAX / ALC / RIL映射之后每次只需要 3 字符。数学上,对 N N N 条包含 k k k 个实体的记录,原始总字符数约为:C r a w = N ⋅ ∑ i = 1 k ∣ n a m e i ∣ C_{raw} = N \cdot \sum_{i=1}^{k} |name_i| Craw=N⋅i=1∑k∣namei∣
AAAK 下近似为:
C a a a k = ∑ i = 1 k ∣ n a m e i ∣ + N ⋅ ∑ i = 1 k ∣ c o d e i ∣ C_{aaak} = \sum_{i=1}^{k} |name_i| + N \cdot \sum_{i=1}^{k} |code_i| Caaak=i=1∑k∣namei∣+N⋅i=1∑k∣codei∣
当 ∣ c o d e i ∣ = 3 |code_i| = 3 ∣codei∣=3 而 ∣ n a m e i ∣ ≈ 6 |name_i| \approx 6 ∣namei∣≈6, N N N 越大,比值 C a a a k / C r a w → 1 / 2 C_{aaak} / C_{raw} \to 1/2 Caaak/Craw→1/2;但 N = 1 N=1 N=1 时甚至比 raw 还长(多了映射头)。这就是 README 里那个"73 vs 66 tokens"的由来。
-
AAAK 作为检索语料会掉分 。在 LongMemEval 上用 AAAK 模式的 drawer 做检索,R@5 从 96.6% 掉到 84.2%------因为被压缩后的文本丢失了可以被 sentence-transformers embed 的语义细节。这直接告诉你 AAAK 的正确用法不是"替代 raw drawer 存进 ChromaDB",而是"在 Layer1 唤醒文件或 prompt 前缀里使用"。
-
raw mode 仍然是 MemPalace 的默认存储 。这一点在
searcher.py里也能看出来------它的query_texts=[query]直接跑在 verbatim drawer 上,从来没有"先 AAAK 再 embed"这一步。
小结一下 Part A :AAAK 是一个漂亮但被作者刻意"降级对外宣传"的子系统。它的本质是"结构化符号摘要",最佳用法是在少量关键记忆上做 token-dense 重写,给 LLM 一个能在 prompt 开头咀嚼的"身份 primer"。它不能取代 verbatim 存储,也不会在小规模上省 token------作者自己把这些坑全部写在了文档和源码里。
3. 🧬 Part B --- 时序知识图谱:把 Neo4j 塞进一个 SQLite 文件
3.1 数据模型:五元组 (s, p, o, valid_from, valid_to)
knowledge_graph.py 开头两段话已经把这个子系统的"身份"钉死了:
python
"""
knowledge_graph.py --- Temporal Entity-Relationship Graph for MemPalace
=====================================================================
Real knowledge graph with:
- Entity nodes (people, projects, tools, concepts)
- Typed relationship edges (daughter_of, does, loves, works_on, etc.)
- Temporal validity (valid_from → valid_to --- knows WHEN facts are true)
- Closet references (links back to the verbatim memory)
Storage: SQLite (local, no dependencies, no subscriptions)
Query: entity-first traversal with time filtering
This is what competes with Zep's temporal knowledge graph.
Zep uses Neo4j in the cloud ($25/mo+). We use SQLite locally (free).
"""
注意这几个关键词:
- temporal validity :每条事实都有
valid_from和valid_to。这不是 "创建时间",是"这条事实在现实世界中的有效区间"。 - closet references:每条 triple 可以指回到它出处的那个 closet/drawer------相当于把 KG 和原始 verbatim 绑在一起,查到"Max 2025-10 loves chess"可以点回具体那条对话。
- SQLite :整个 KG 就是一个
.sqlite3文件。对比 Zep 的 Graphiti 跑在 Neo4j 云上,定价 $25/月起。
传统知识图谱一直是 triple 三元组 ( s , p , o ) (s, p, o) (s,p,o)。MemPalace 的 KG 加了两维时间,把它扩成:
Fact = ( s , p , o , t f r o m , t t o , c , src ) \text{Fact} = (s, p, o, t_{from}, t_{to}, c, \text{src}) Fact=(s,p,o,tfrom,tto,c,src)
其中 c c c 是 confidence, src \text{src} src 是指向 closet/source_file 的反向引用。
3.2 SQLite schema 与索引
python
def _init_db(self):
conn = self._conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT DEFAULT 'unknown',
properties TEXT DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS triples (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object TEXT NOT NULL,
valid_from TEXT,
valid_to TEXT,
confidence REAL DEFAULT 1.0,
source_closet TEXT,
source_file TEXT,
extracted_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subject) REFERENCES entities(id),
FOREIGN KEY (object) REFERENCES entities(id)
);
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
""")
画成 schema 图:
┌───────────────────────────┐ ┌──────────────────────────────┐
│ entities │ │ triples │
├───────────────────────────┤ ├──────────────────────────────┤
│ id PRIMARY KEY │◄──┐ │ id PK │
│ name TEXT │ │ │ subject FK → ent.id │
│ type TEXT │ ├───────┤ predicate TEXT │
│ properties TEXT (json) │ │ │ object FK → ent.id │
│ created_at TEXT │ │ │ valid_from TEXT (iso) │
└───────────────────────────┘ │ │ valid_to TEXT (iso) │
│ │ confidence REAL │
│ │ source_closet TEXT │
│ │ source_file TEXT │
│ │ extracted_at TEXT │
│ └──────────────────────────────┘
│ │
└────────────────┘
(两个 FK 都指回 entities.id)
Index:
idx_triples_subject --- 按主语查
idx_triples_object --- 按宾语查 (incoming edges)
idx_triples_predicate --- 按关系类型查 ("谁 married_to 谁")
idx_triples_valid --- 时间过滤
几个设计决定值得停下来想一想:
-
entity id 是 slugified 的 name:
pythondef _entity_id(self, name: str) -> str: return name.lower().replace(" ", "_").replace("'", "")"Max Chen" →
max_chen。这意味着重名会自动合并。对个人用户的 KG 来说这是个合理假设(你的生活里不会有两个都叫 Max Chen 的人),但对企业 KG 就不够用。MemPalace 清楚自己是个人记忆系统,不解决这个问题。 -
properties 存为 JSON 字符串 :经典的 SQLite schemaless tradeoff,避免为
gender / birthday / role / ...加一堆列。 -
WAL journal mode:
pythondef _conn(self): conn = sqlite3.connect(self.db_path, timeout=10) conn.execute("PRAGMA journal_mode=WAL") return connSQLite 的 write-ahead logging 允许多读一写并发。因为 MCP server 会在多个工具调用里同时读这张表,WAL 是必须的。
-
没有 uniqueness on (s,p,o) :历史可以保留多条"同主宾谓但时间区间不同"的 triple。但
add_triple()会做一次"同 (s,p,o) 且当前仍有效"的去重,避免无限追加同一条当前事实。
3.3 add_triple / invalidate / query_entity / as_of
add_triple 的核心逻辑:
python
def add_triple(self, subject, predicate, obj,
valid_from=None, valid_to=None,
confidence=1.0, source_closet=None, source_file=None):
sub_id = self._entity_id(subject)
obj_id = self._entity_id(obj)
pred = predicate.lower().replace(" ", "_")
conn = self._conn()
# Auto-create entities
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject))
conn.execute("INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj))
# De-dup: same (s,p,o) still valid → do not insert
existing = conn.execute(
"SELECT id FROM triples "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchone()
if existing:
conn.close()
return existing[0]
triple_id = f"t_{sub_id}_{pred}_{obj_id}_" + \
hashlib.md5(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:8]
conn.execute("""INSERT INTO triples
(id, subject, predicate, object, valid_from, valid_to,
confidence, source_closet, source_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(triple_id, sub_id, pred, obj_id, valid_from, valid_to,
confidence, source_closet, source_file))
...
三点值得注意:
- auto-create entities :你不用先
add_entity,写 triple 的时候主宾会自动补 entity。 - id 包含时间戳 hash:防止同一条 (s,p,o) 在不同时间点被 re-insert 时 primary key 冲突。
- valid_to IS NULL 表示"仍在发生":所有"当前事实"通过这一个条件筛选。
invalidate 把"仍在发生"翻转成"到此为止":
python
def invalidate(self, subject, predicate, obj, ended=None):
sub_id = self._entity_id(subject)
obj_id = self._entity_id(obj)
pred = predicate.lower().replace(" ", "_")
ended = ended or date.today().isoformat()
conn = self._conn()
conn.execute(
"UPDATE triples SET valid_to=? "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(ended, sub_id, pred, obj_id),
)
conn.commit()
conn.close()
注意它不删除记录,只是填上 valid_to 。这样历史保留,你仍然可以 query_entity("Max", as_of="2025-06-15") 问"Max 在 2025 年 6 月 15 日那天都有哪些正在进行的事实"。
query_entity + as_of 是 KG 最关键的 API:
python
def query_entity(self, name, as_of=None, direction="outgoing"):
eid = self._entity_id(name)
conn = self._conn()
results = []
if direction in ("outgoing", "both"):
query = ("SELECT t.*, e.name as obj_name FROM triples t "
"JOIN entities e ON t.object = e.id WHERE t.subject = ?")
params = [eid]
if as_of:
query += (" AND (t.valid_from IS NULL OR t.valid_from <= ?) "
" AND (t.valid_to IS NULL OR t.valid_to >= ?)")
params.extend([as_of, as_of])
for row in conn.execute(query, params).fetchall():
results.append({
"direction": "outgoing",
"subject": name,
"predicate": row[2],
"object": row[10],
"valid_from": row[4],
"valid_to": row[5],
"confidence": row[6],
"source_closet": row[7],
"current": row[5] is None,
})
...
这里的时间过滤翻译成数学谓词是:
valid ( f , t ) ⟺ ( f . t f r o m = ∅ ∨ f . t f r o m ≤ t ) ∧ ( f . t t o = ∅ ∨ f . t t o ≥ t ) \text{valid}(f, t) \iff (f.t_{from} = \emptyset \lor f.t_{from} \le t) \land (f.t_{to} = \emptyset \lor f.t_{to} \ge t) valid(f,t)⟺(f.tfrom=∅∨f.tfrom≤t)∧(f.tto=∅∨f.tto≥t)
直观意思是:"要么事实是开放起点、要么开始时间在 t t t 之前;同时要么事实仍在进行、要么结束时间在 t t t 之后"。任何命中这条区间谓词的 triple 都算 "as_of=t 时刻成立"。
3.4 时序有效性窗口的数学描述
把 KG 的每条事实想成一段区间 [ t f r o m , t t o ] [t_{from}, t_{to}] [tfrom,tto](开区间和 − ∞ / + ∞ -\infty / +\infty −∞/+∞ 由 NULL 表示),那么对某个实体 e e e 在时间 t t t 的 "snapshot" 就是:
S e ( t ) = { ( s , p , o ) ∣ s = e ∧ t f r o m ≤ t ≤ t t o } S_e(t) = \{ (s, p, o) \mid s = e \land t_{from} \le t \le t_{to} \} Se(t)={(s,p,o)∣s=e∧tfrom≤t≤tto}
(这里对 NULL 端点的处理同上面的谓词。)
作为时间函数的关系流 :对同一对 ( s , p , o ) (s, p, o) (s,p,o),随着 invalidate 和新的 add_triple,会生成一条"真假值切换"的时间线:
predicate = "has_interest", object = "soccer"
valid: ─────────────■───────────■────────────■─────────────■─────▶ (time)
┌─┐ ┌─┐ ┌─┐ ┌─┐
│ │ soccer │ │ break │ │ chess │ │ ...
└─┘ └─┘ └─┘ └─┘
▲ ▲ ▲ ▲
2024-03 2024-09 2025-01 2025-10
(vfrom) (vto) (vfrom) (vfrom)
一个人的兴趣不是"一次性"的,KG 把这个事实天然地写成了时间序列。对 LLM 系统来说这是关键------当用户问 "what does Max love?" 时,你希望 as_of=today 的 snapshot;当用户问 "what did Max love back in 2024?" 时,你希望 as_of="2024-06-01" 的 snapshot。MemPalace 的 KG 一条 SQL 就解决。
timeline 查询把这层显式暴露:
python
def timeline(self, entity_name=None):
...
rows = conn.execute("""
SELECT t.*, s.name, o.name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
WHERE (t.subject = ? OR t.object = ?)
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""", (eid, eid)).fetchall()
按 valid_from 升序把一个人所有相关事实串起来,这是给 LLM 讲故事的天然顺序。
seed_from_entity_facts :MemPalace 还提供了一个"ground truth 灌种"的入口。比如从一份 fact_checker.py 的家庭档案里,可以一次性把"Max 是 Alice 的儿子、Max 喜欢游泳、Alice 是 Bob 的伴侣"全部 bootstrap 进 KG:
python
def seed_from_entity_facts(self, entity_facts: dict):
for key, facts in entity_facts.items():
name = facts.get("full_name", key.capitalize())
etype = facts.get("type", "person")
self.add_entity(name, etype, {
"gender": facts.get("gender", ""),
"birthday": facts.get("birthday", ""),
})
parent = facts.get("parent")
if parent:
self.add_triple(name, "child_of", parent.capitalize(),
valid_from=facts.get("birthday"))
partner = facts.get("partner")
if partner:
self.add_triple(name, "married_to", partner.capitalize())
...
for interest in facts.get("interests", []):
self.add_triple(name, "loves", interest.capitalize(),
valid_from="2025-01-01")
这让 MemPalace 在"onboarding 的 10 秒内"就能得到一个有结构的 KG 骨架,而不是从零等 LLM 自己慢慢抽。
3.5 palace_graph.py:房间级导航图与 tunnels
knowledge_graph.py 是"关于人、事物、事实"的符号图,而 palace_graph.py 是另一个维度的图:关于"房间"本身的导航图。它的节点是 room,边是"两个 wing 共享同一个 room 的 tunnel"。
先看构造函数:
python
def build_graph(col=None, config=None):
"""
Build the palace graph from ChromaDB metadata.
Returns:
nodes: dict of {room: {wings: set, halls: set, count: int}}
edges: list of {room, wing_a, wing_b, hall} --- one per tunnel crossing
"""
if col is None:
col = _get_collection(config)
if not col:
return {}, []
total = col.count()
room_data = defaultdict(lambda: {"wings": set(), "halls": set(), "count": 0, "dates": set()})
offset = 0
while offset < total:
batch = col.get(limit=1000, offset=offset, include=["metadatas"])
for meta in batch["metadatas"]:
room = meta.get("room", "")
wing = meta.get("wing", "")
hall = meta.get("hall", "")
date = meta.get("date", "")
if room and room != "general" and wing:
room_data[room]["wings"].add(wing)
if hall:
room_data[room]["halls"].add(hall)
if date:
room_data[room]["dates"].add(date)
room_data[room]["count"] += 1
if not batch["ids"]:
break
offset += len(batch["ids"])
...
这里最关键的观察是:palace_graph 根本没有自己的存储层 。它只是把 ChromaDB collection 里所有 drawer 的 metadata 扫一遍,然后在内存里按 room 聚合。所有"图结构"都是从元数据派生的视图,完全无状态、随时可重建。
tunnel 定义 :一个 room 如果在 ≥ 2 个 wing 里都出现过,就认为它是一条"隧道"。比如 chromadb-setup 可能同时出现在 wing_code(你写代码的项目)和 wing_blog(你写博客讨论 ChromaDB),那它就把这两个 wing 连了起来:
python
edges = []
for room, data in room_data.items():
wings = sorted(data["wings"])
if len(wings) >= 2:
for i, wa in enumerate(wings):
for wb in wings[i + 1:]:
for hall in data["halls"]:
edges.append({
"room": room,
"wing_a": wa,
"wing_b": wb,
"hall": hall,
"count": data["count"],
})
BFS traverse 允许你从一个 room 出发,按"共享 wing"走 k 跳:
python
def traverse(start_room: str, col=None, config=None, max_hops: int = 2):
nodes, edges = build_graph(col, config)
if start_room not in nodes:
return {
"error": f"Room '{start_room}' not found",
"suggestions": _fuzzy_match(start_room, nodes),
}
start = nodes[start_room]
visited = {start_room}
results = [{"room": start_room, "wings": start["wings"],
"halls": start["halls"], "count": start["count"], "hop": 0}]
frontier = [(start_room, 0)]
while frontier:
current_room, depth = frontier.pop(0)
if depth >= max_hops:
continue
current = nodes.get(current_room, {})
current_wings = set(current.get("wings", []))
for room, data in nodes.items():
if room in visited:
continue
shared_wings = current_wings & set(data["wings"])
if shared_wings:
visited.add(room)
results.append({
"room": room,
"wings": data["wings"],
"halls": data["halls"],
"count": data["count"],
"hop": depth + 1,
"connected_via": sorted(shared_wings),
})
if depth + 1 < max_hops:
frontier.append((room, depth + 1))
results.sort(key=lambda x: (x["hop"], -x["count"]))
return results[:50]
这条代码是个标准 BFS,但它的图定义是即时的 ------每次调用都重建全图。对于一个个人用户规模的 palace (几千到几万 drawer),col.get(limit=1000, offset=...) 分批扫整个 collection 也就秒级。作者直接忽略增量更新问题,因为简单胜过优化。
find_tunnels 是一个更专注的用法:
python
def find_tunnels(wing_a=None, wing_b=None, col=None, config=None):
nodes, edges = build_graph(col, config)
tunnels = []
for room, data in nodes.items():
wings = data["wings"]
if len(wings) < 2:
continue
if wing_a and wing_a not in wings:
continue
if wing_b and wing_b not in wings:
continue
tunnels.append({
"room": room,
"wings": wings,
"halls": data["halls"],
"count": data["count"],
"recent": data["dates"][-1] if data["dates"] else "",
})
tunnels.sort(key=lambda x: -x["count"])
return tunnels[:50]
这个函数的语义是:"给我所有连接 wing_A 和 wing_B 的 room"。这在 LLM prompt 里非常有用------假设用户问 "我之前在研究 ChromaDB 时,和我写博客时的想法有什么交叉?",你只需要 find_tunnels("wing_code", "wing_blog"),就得到了所有跨域主题,再对每个主题做一次 semantic search 拉 verbatim drawer 回来。
3.6 对比:MemPalace KG vs Zep Graphiti vs Neo4j
把这一节的系统性放到一张表里:
| 维度 | MemPalace KG | Zep Graphiti | Neo4j (原生) |
|---|---|---|---|
| 存储 | SQLite 单文件 (~/.mempalace/knowledge_graph.sqlite3) |
Neo4j 云实例 | 本地或云 Neo4j |
| 依赖 | Python stdlib sqlite3 |
Neo4j driver + 云服务 | Neo4j server + bolt driver |
| 部署 | 零部署,跟随 pip 包 | 云订阅 $25/月起 | 需独立运维 |
| 查询语言 | Python API + 少量 SQL | Cypher | Cypher |
| 时序支持 | 原生 valid_from / valid_to 双列 |
节点和边上的时间属性 | 用户需自建 property |
| 关系类型 | 任意字符串 predicate | typed edges | typed edges |
| Embedding 集成 | 无(纯符号) | 有(triple + embed) | 需第三方 |
| Bi-temporal (t_valid + t_sys) | 只 t_valid (+ extracted_at) | 两者都有 | 手动实现 |
| 数据规模 | 个人级 (104~105 triples) | 企业级 | PB 级 |
| 查询示例 | query_entity("Max", as_of="2026-01") |
MATCH (n:Person {name:'Max'})-[r]->(m) WHERE r.valid_from <= date('2026-01-01') |
同上 Cypher |
| 反向溯源 | source_closet / source_file 字段 |
有 episode ref | 自建 |
| 成本 | $0 | $25+/月 | 自运维 |
Why MemPalace 敢用 SQLite 而不是 Neo4j?
因为个人记忆规模就到这里。一个用户十年对话的 KG,撑死几十万 triple,idx_triples_subject 这种 B-tree 索引下 SQLite 的点查永远是毫秒级。真正需要图算法(社区发现、PageRank、复杂多跳 Cypher)的场景,个人用户几乎遇不到。作者的取舍非常清晰:用 10% 的代码复杂度换 95% 的实际查询场景。
Why Zep 要走 Neo4j? 因为 Zep 是 B2B,面对多租户 + 企业级 KG 规模 + 给 LLM agent 做工具调用的中间件。它们需要 Cypher、需要扩展能力、需要 SLA------这是一条完全不同的产品曲线。
Why 这篇文章把 KG 和 palace_graph 都塞进 "Part B"?
因为这两个图解决的其实是同一问题的两面:KG 是"事实的图"(人和关系),palace_graph 是"语料的图"(房间和项目)。一个回答"WHAT/WHO",另一个回答"WHERE in my corpus"。在 MCP 工具里,它们经常被同一次查询联动使用------先用 KG 找到"Max 2026-01 时在做什么",再用 palace_graph 找到"和那件事相关的其他 room 散落在哪几个 wing 里",然后走到 searcher 拉原文。
4. 🔍 Part C --- 语义检索:152 行的 searcher.py 与 where 过滤
4.1 PersistentClient + mempalace_drawers collection
152 行的 searcher.py 可能是 MemPalace 整个仓库里密度最高 的文件------它几乎就是"把 ChromaDB 的 query API 包一层用户友好的打印和错误处理"。但看似简单的包装,恰好说明 MemPalace 的一个核心取舍:它不做检索层的轮子,直接吃 ChromaDB。
入口非常朴素:
python
import chromadb
def search(query: str, palace_path: str,
wing: str = None, room: str = None, n_results: int = 5):
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
raise SearchError(f"No palace found at {palace_path}")
...
两行核心:PersistentClient(path=palace_path) 打开 palace 目录下的 ChromaDB,get_collection("mempalace_drawers") 拿到那一个(也是唯一一个)collection。整个 MemPalace 的 vector store 结构就是:
<palace_path>/
├── chroma.sqlite3 ← ChromaDB 自己的元数据 sqlite
└── <collection_uuid>/
├── data_level0.bin ← HNSW 向量索引
├── header.bin
├── link_lists.bin
└── length.bin
没有多 collection,没有分库,没有 namespace。所有 drawer 都在 mempalace_drawers 一个集合里,靠 metadata 的 wing / room / hall / date 字段去切分。这是 MemPalace 的 "flat everything, filter metadata" 哲学。
4.2 默认 embedding:sentence-transformers / ONNX 与 ARM64 坑
ChromaDB 的默认 embedding function 是 chromadb.utils.embedding_functions.SentenceTransformerEmbeddingFunction,模型是 all-MiniLM-L6-v2,384 维,384 维向量,通过 ONNX runtime 运行。MemPalace 在 README 里明确说它不包装第二层 embedding,就用 ChromaDB 默认的。
text
│
▼
sentence-transformers (all-MiniLM-L6-v2 via ONNX)
│ 384-dim vector
▼
ChromaDB HNSW index (cosine space)
│ ANN approx nearest neighbors
▼
top-k hits with distances
│ similarity = 1 - distance
▼
[(doc, metadata, similarity)...]
这里的数学部分很标准------ChromaDB 默认是 cosine 空间,对向量 u , v u, v u,v:
d ( u , v ) = 1 − u ⋅ v ∥ u ∥ ⋅ ∥ v ∥ , sim ( u , v ) = 1 − d ( u , v ) = u ⋅ v ∥ u ∥ ⋅ ∥ v ∥ d(u, v) = 1 - \frac{u \cdot v}{\|u\| \cdot \|v\|}, \qquad \text{sim}(u, v) = 1 - d(u, v) = \frac{u \cdot v}{\|u\| \cdot \|v\|} d(u,v)=1−∥u∥⋅∥v∥u⋅v,sim(u,v)=1−d(u,v)=∥u∥⋅∥v∥u⋅v
searcher.py 里就是这么把它转换回 similarity 的:
python
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(1 - dist, 3)
ARM64 / CoreML 坑:README 的 "What's still broken" 列表里有一条:
macOS ARM64 segfault (#74) --- ONNX runtime 在某些 macOS ARM64 构建上用 CoreML provider 时会 segfault,导致 embedding 不稳定。
这是纯生态兼容性问题 ,不是 MemPalace 自己的 bug。但它提醒你:当你把"默认 embedding"当作黑盒时,所有底层依赖的运行时坑都会落在用户头上。MemPalace 暂时的解决方案是"pin 一个 tested 的 ChromaDB 版本范围 (Issue #100)"。对我们做 agent memory 的人来说,这条教训是:embedding 的稳定性和可复现性是一条必须关注的工程红线。
4.3 where filter:wing/room 元数据过滤的工程价值
整个 searcher.py 最有价值的"策略"就两段:构造 where 和把它塞进 col.query():
python
# Build where filter
where = {}
if wing and room:
where = {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
where = {"wing": wing}
elif room:
where = {"room": room}
try:
kwargs = {
"query_texts": [query],
"n_results": n_results,
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
results = col.query(**kwargs)
翻译成白话:MemPalace 的"检索增益"主要来自"先按 wing/room 切片再做 ANN",而不是任何花哨的 hybrid rerank。这一点作者在 4 月 7 日的勘误里亲手写死:
"+34% palace boost" was misleading. That number compares unfiltered search to wing+room metadata filtering. Metadata filtering is a standard ChromaDB feature, not a novel retrieval mechanism. Real and useful, but not a moat.
换句话说:之前说 palace 架构带来了 +34% 的检索提升,这是真的,但它来自 ChromaDB 的 metadata where filter,不是新算法。MemPalace 选择在 miner 层下功夫把 wing/room 打对,下游的"检索魔法"只是一个标准的 ANN + filter。
为什么这还是值得做? 因为对个人记忆而言,把"过滤维度"做成用户可理解的自然语言(wing=项目、room=具体想法)比做成嵌入空间里的子簇重要得多。用户可以明确说"只在 wing_code 里搜 chromadb",这是任何纯向量系统给不了的可解释性。
数学形式化 :设 drawer 集合 D D D,每个 drawer d d d 有向量 v ⃗ d \vec{v}_d v d 和元数据 m d m_d md。朴素向量检索是:
topk ( q , D ) = top- k d ∈ D sim ( v ⃗ q , v ⃗ d ) \text{topk}(q, D) = \underset{d \in D}{\text{top-}k} \ \text{sim}(\vec{v}_q, \vec{v}_d) topk(q,D)=d∈Dtop-k sim(v q,v d)
MemPalace 的 filtered 检索先做子集筛:
D f = { d ∈ D ∣ m d . wing = w ∧ m d . room = r } D_f = \{ d \in D \mid m_d.\text{wing} = w \land m_d.\text{room} = r \} Df={d∈D∣md.wing=w∧md.room=r}
再在子集上做 top-k:
topk ( q , D f ) = top- k d ∈ D f sim ( v ⃗ q , v ⃗ d ) \text{topk}(q, D_f) = \underset{d \in D_f}{\text{top-}k} \ \text{sim}(\vec{v}_q, \vec{v}_d) topk(q,Df)=d∈Dftop-k sim(v q,v d)
Why 这能涨分? 因为在真实对话语料里,同一个词(比如 "client")在 wing_code("chromadb persistent client")和 wing_personal("I met a new client at work")里有完全不同的语义。纯向量空间把它们混在一起时 top-k 会飘;先按 wing 切再搜,相当于给了 embedding 一个外部 "domain prior"。在 MemPalace 的 rooms mode benchmark 上,这一步能稳稳提供几个点的 R@5,作者记录的就是那个"看起来很可观但机制平平"的 +34%。
4.4 search vs search_memories:CLI 打印 vs MCP 数据
searcher.py 里有两个长得几乎一样的函数,search 和 search_memories。它们的 body 很像,但差异点很有意思:
python
def search(query, palace_path, wing=None, room=None, n_results=5):
...
# 对外是"人类可读打印"
print(f"\n{'=' * 60}")
print(f' Results for: "{query}"')
...
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(1 - dist, 3)
source = Path(meta.get("source_file", "?")).name
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
print(f" [{i}] {wing_name} / {room_name}")
print(f" Source: {source}")
print(f" Match: {similarity}")
print()
for line in doc.strip().split("\n"):
print(f" {line}")
print()
python
def search_memories(query, palace_path, wing=None, room=None, n_results=5) -> dict:
"""
Programmatic search --- returns a dict instead of printing.
Used by the MCP server and other callers that need data.
"""
...
hits = []
for doc, meta, dist in zip(docs, metas, dists):
hits.append({
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?")).name,
"similarity": round(1 - dist, 3),
})
return {
"query": query,
"filters": {"wing": wing, "room": room},
"results": hits,
}
两个函数等价于同一套检索逻辑,一个对 CLI 用户负责,一个对 MCP 调用者负责。为什么不做成一个函数加 as_dict: bool 参数?因为两者的错误路径 不一样------CLI 版本会 print 然后 raise SearchError,MCP 版本会 log.error 然后 return {"error": ..., "hint": ...}。MCP server 绝不能抛未捕获异常到 stdout,因为它会污染 JSON-RPC 通道。
这就引出了第 08 篇将要展开的主题:MCP server 和 CLI 是两种完全不同的调用契约。这里 searcher.py 的双份实现是那个世界观的先兆。
4.5 对比:ChromaDB 检索 vs OpenClaw Hybrid Search
我们在 OpenClaw 01-04 系列里讲过它的 Hybrid Search:BM25 + dense + rerank。那是企业级 RAG 的标准栈。把两者放一起看:
| 维度 | MemPalace (ChromaDB) | OpenClaw Hybrid Search |
|---|---|---|
| Dense retrieval | sentence-transformers all-MiniLM-L6-v2 (384d) |
可插拔 (BGE / E5 / text-embedding-3) |
| Sparse retrieval | 无 | BM25 / SPLADE |
| Rerank | 无 | Cross-encoder (bge-reranker) |
| Metadata filter | where (JSON clause) |
Elasticsearch/vector DB filter DSL |
| 索引 | HNSW (ChromaDB 内置) | HNSW + inverted index |
| 距离函数 | cosine | cosine / dot |
| 返回 | top-k doc + metadata + distance | top-k + rerank score |
| 部署 | 单机单文件 | 多服务 (vector DB + BM25 engine + rerank service) |
| 语料规模 | 个人级 104~105 | 企业级 10^7+ |
| 调用延迟 | 数十毫秒 | 数百毫秒到秒级 |
| 依赖/运维 | 无 | 中等到重 |
| 精度 | 在 LongMemEval R@5 上 96.6% | 在 MTEB/BEIR 上多数场景更高 |
MemPalace 为什么不做 hybrid? 三个原因:
- 精度够用。在 LongMemEval 上 raw + metadata filter 已经能拿到 96.6% R@5,业界领先梯队。追加 BM25 的边际收益在这个规模上很小。
- 运维成本。Hybrid 意味着要同时维护 sparse 索引和 dense 索引,对单用户的本地工具是负担。
- 个人语料长尾稀疏。BM25 在"关键词召回"上的优势建立在大语料 + 高频词分布上。你的个人对话里 "MCP"、"chromadb"、"Max" 的 IDF 很奇怪,BM25 反而不稳定。
为什么 OpenClaw 要做 hybrid? 企业语料里查全率 是底线需求。一个法律条款、一个函数名、一个产品 ID,用户打出来时希望必然命中。BM25 的 exact match 行为对这类"标识符查询"是刚需,cross-encoder rerank 则是精排的标配。
这两种产品形态没有优劣之分,它们服务的是完全不同规模和权责的记忆场景。本篇重点是让你看到:MemPalace 是如何"克制地只用 ChromaDB + where filter"就拿到 SOTA 分数的,而这个克制的成本是------作者必须在 miner 层(上一篇的 convo_miner 和 room_detector_local)把 wing/room 打得非常干净。检索层的极简,是挖掘层细致工作的回报。
5. 📚 小结与下篇预告
这一篇我们拆解了 MemPalace 最有工程味的三件套:
🔤 AAAK 方言 (dialect.py + entity_registry.py)。一个 lossy 的结构化符号摘要格式,任何 LLM 直接可读,不需要 decoder。核心机制是三张表(emotion codes、flag signals、stop words)加一个三层 entity registry(onboarding / learned / wiki_cache)。作者在 4 月 7 日公开勘误了"30x 压缩"和"+34% boost"的早期宣传,把 AAAK 重新定位为"在重复实体 at scale 时有收益的 prompt 前缀工具",并承认 AAAK mode 在 LongMemEval 上(84.2%)明显弱于 raw mode(96.6%)。技术诚实度本身就是 AAAK 这套设计的一部分。
🧬 时序知识图谱 (knowledge_graph.py + palace_graph.py)。在一份 SQLite 文件里实现了 (subject, predicate, object, valid_from, valid_to, confidence, source_closet) 的七维时序事实表,加四个 B-tree 索引。add_triple / invalidate / query_entity / as_of / timeline / seed_from_entity_facts 这一组 API 足以覆盖 Zep Graphiti 对个人用户的大部分用法,但成本是 $0、部署是零。palace_graph.py 在 ChromaDB 元数据之上 BFS 构建房间导航图,回答"哪些 room 在多个 wing 之间架桥"这一类问题------它是一个无状态的派生视图,随时可重建。
🔍 语义检索 (searcher.py)。152 行代码,本质就是 chromadb.PersistentClient + get_collection + query(..., where=...)。所有"魔法"都来自两件事:一是 miner 层在 metadata 里打好了 wing/room/hall/date,二是 query 时用 where filter 做子集 ANN。数学上就是"先按 metadata 切 D f D_f Df,再在 D f D_f Df 上做 top-k by cosine similarity"。诚实地讲,这不是新的检索算法,但在个人规模的记忆场景下它拿到了 96.6% R@5。
三者怎么协同:
User query ─────────────────────────────┐
│
▼
┌─────────────────────────────────────────────┐
│ (Optional) KG lookup for as_of / entity │
│ knowledge_graph.query_entity(...) │
└───────┬─────────────────────────────────────┘
│ gives (wing, room) hints / entity filter
▼
┌─────────────────────────────────────────────┐
│ palace_graph.find_tunnels(...) / traverse │
│ widen search to cross-wing rooms │
└───────┬─────────────────────────────────────┘
│ gives candidate rooms
▼
┌─────────────────────────────────────────────┐
│ searcher.search_memories(query, wing, room)│
│ ChromaDB ANN + where filter │
└───────┬─────────────────────────────────────┘
│ verbatim drawers
▼
┌─────────────────────────────────────────────┐
│ Layer1 + AAAK (dialect) prompt prefix │
│ + verbatim drawer context │
└───────┬─────────────────────────────────────┘
▼
LLM answer
下一篇 ------【Agent Memory篇】08:MemPalace 的 MCP 服务器、CLI 与端到端实战:
mcp_server.py里 20+ 个 MCP 工具是怎么把 KG / palace_graph / searcher / dialect 一层层暴露给 Claude、Cursor、Codex 等 agent 的;cli.py和hooks_cli.py如何让你在终端里mempalace init / mine / search;config.py和~/.mempalace/的目录布局;- 一套从零到 palace的实战:onboarding → mining → kg_add → search → layer1 → 塞进 Claude Code 的 system prompt;
- 最后会把 MemPalace、Zep、Letta、OpenClaw 四个系统放在同一张"适用场景矩阵"里做收尾对比。
08 篇之后,我们会回到 agent memory 的更高层话题:记忆的遗忘、压缩、隐私与人类主动权。MemPalace 三部曲会在第 08 篇画上句号。
6. 📖 参考文献
源码 (MemPalace v3.0.14)
mempalace/dialect.py(1075 行) --- AAAK 方言编码/解码、Layer1 生成mempalace/entity_registry.py(639 行) --- 三层 entity registry + Wikipedia fallbackmempalace/knowledge_graph.py(387 行) --- SQLite 时序知识图谱mempalace/palace_graph.py(227 行) --- ChromaDB 元数据之上的房间导航图mempalace/searcher.py(152 行) --- ChromaDB 检索入口(CLI + MCP 两版)mempalace/README.md--- 特别是 "A note from Milla & Ben --- April 7 2026" 这一节- GitHub:
milla-jovovich/mempalace
竞品与相关系统
- Zep / Graphiti --- Temporal Knowledge Graph for LLM Agents, 2024. https://github.com/getzep/graphiti
- Letta (formerly MemGPT) --- MemGPT: Towards LLMs as Operating Systems, Packer et al., 2023. arXiv:2310.08560
- Neo4j --- "Temporal properties in graph databases", Neo4j Docs
- ChromaDB --- official docs, https://docs.trychroma.com/
Embedding / 检索
- Reimers & Gurevych, "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks", EMNLP 2019
sentence-transformers/all-MiniLM-L6-v2--- HuggingFace 模型卡- ONNX Runtime --- https://onnxruntime.ai/
- Malkov & Yashunin, "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs" (HNSW), IEEE TPAMI 2018
- Robertson et al., "Okapi at TREC-3" (BM25)
时序与知识图谱理论
- Gutierrez & Hogan, "Knowledge Graphs: A survey", ACM Computing Surveys, 2021
- Snodgrass, "Developing Time-Oriented Database Applications in SQL", Morgan Kaufmann, 1999(时序数据库的经典参考,
valid_from / valid_to双列语义的源头) - Leblay & Chekol, "Deriving Validity Time in Knowledge Graph", WWW 2018
LongMemEval 基准
- Wu et al., "LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory", 2024
- Reproducible runner:
benchmarks/目录 - 独立复现报告: @gizmax,
milla-jovovich/mempalace#39