【Agent Memory篇】07:MemPalace 的 AAAK 方言、知识图谱与语义检索

系列导航:这是「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_drawers collection](#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 search vs search_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)
  • [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 要放一起讲? 因为这三块分别回答了一个记忆系统的三个根本问题:

  1. 怎么把"长"变"短"而不丢可读性? ------ AAAK 方言。
  2. 怎么把"散乱的句子"变成"可以按时间切片的事实"? ------ 时序知识图谱。
  3. 怎么让"一个问句"精确找回相关 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()
            ...

这段代码有一个严重的现实问题:英文里有一堆"看起来像名字的常用词"。WillGraceMarkAprilHopeJoyHunter 既是人名也是动词/名词/月份。在 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 想要:

  1. 离线:不联网也要能跑 onboarding + learned 这两层;
  2. 便宜:每一次 mine、每一条 drawer 都会被过一遍,LLM 调用会爆钱;
  3. 可控:用户对"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 → determfrustrated → frustworried → anx
  • _detect_flags() 命中 decided → DECISIONdatabase → TECHNICAL(虽然这里是 ChromaDB,但走的是 switched → DECISIONdatabase 关键字)。

整个过程纯正则和字典,在我本地 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)//3 was 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.

翻译成技术判断:

  1. AAAK 在小规模文本上不省 token 。它的收益来自"重复实体"的编码复用。假设有 N 条 drawer,都涉及同一组人物 { P 1 , P 2 , ... , P k } \{P_1, P_2, \dots, P_k\} {P1,P2,...,Pk},原始写法每次都要出现 MaxwellAlice ChenRiley 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"的由来。

  2. AAAK 作为检索语料会掉分 。在 LongMemEval 上用 AAAK 模式的 drawer 做检索,R@5 从 96.6% 掉到 84.2%------因为被压缩后的文本丢失了可以被 sentence-transformers embed 的语义细节。这直接告诉你 AAAK 的正确用法不是"替代 raw drawer 存进 ChromaDB",而是"在 Layer1 唤醒文件或 prompt 前缀里使用"。

  3. 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_fromvalid_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     --- 时间过滤

几个设计决定值得停下来想一想:

  1. entity id 是 slugified 的 name

    python 复制代码
    def _entity_id(self, name: str) -> str:
        return name.lower().replace(" ", "_").replace("'", "")

    "Max Chen" → max_chen。这意味着重名会自动合并。对个人用户的 KG 来说这是个合理假设(你的生活里不会有两个都叫 Max Chen 的人),但对企业 KG 就不够用。MemPalace 清楚自己是个人记忆系统,不解决这个问题。

  2. properties 存为 JSON 字符串 :经典的 SQLite schemaless tradeoff,避免为 gender / birthday / role / ... 加一堆列。

  3. WAL journal mode

    python 复制代码
    def _conn(self):
        conn = sqlite3.connect(self.db_path, timeout=10)
        conn.execute("PRAGMA journal_mode=WAL")
        return conn

    SQLite 的 write-ahead logging 允许多读一写并发。因为 MCP server 会在多个工具调用里同时读这张表,WAL 是必须的。

  4. 没有 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%。

searcher.py 里有两个长得几乎一样的函数,searchsearch_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 的双份实现是那个世界观的先兆。

我们在 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? 三个原因:

  1. 精度够用。在 LongMemEval 上 raw + metadata filter 已经能拿到 96.6% R@5,业界领先梯队。追加 BM25 的边际收益在这个规模上很小。
  2. 运维成本。Hybrid 意味着要同时维护 sparse 索引和 dense 索引,对单用户的本地工具是负担。
  3. 个人语料长尾稀疏。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.pyhooks_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 fallback
  • mempalace/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
相关推荐
花千树-0107 小时前
Agent核心架构:感知-规划-行动-观察循环
aigc·agent·ai agent·ai harness·ai react·agent 模式
key_3_feng10 小时前
AI Agent的入门开发指南
ai agent
都市凡尘@Paraverse11 小时前
Agent 心智架构:感知 - 推理 - 行动循环|学习笔记
ai agent·datawhale·agent设计模式
Thomas.Sir1 天前
AI 医疗之罕见病/疑难病辅助诊断系统从算法到实现【表型驱动与知识图谱推理】
人工智能·算法·ai·知识图谱
这张生成的图像能检测吗1 天前
(论文速读)基于知识图谱构建的大型工业设备故障诊断模型
人工智能·深度学习·知识图谱·故障诊断
arvin_xiaoting1 天前
OpenClaw学习总结_IV_认证与安全_5:Secret管理与轮换详解
ai agent·openclaw·认证安全
行者-全栈开发1 天前
AI 驱动的智能行程规划系统:腾讯地图 Map Skills 实战
人工智能·路径规划·ai agent·多人协同·tool calling·mcp 协议·poi 检索
华农DrLai2 天前
什么是LLM做推荐的三种范式?Prompt-based、Embedding-based、Fine-tuning深度解析
人工智能·深度学习·prompt·transformer·知识图谱·embedding
zhangshuang-peta2 天前
通过 MCP 控制平面引入技能
人工智能·机器学习·ai agent·mcp·peta