【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∑1p matches x,sconcept(x)=c∈C∑1c 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
相关推荐
长谷深风1113 小时前
为什么你的 Tool 总被模型选错?
java·大数据·ai·llm·ai agent·工具设计·agent设计
仙逆GPT6 小时前
ChatGPT、Codex趋势:为什么AI时代最重要的能力,不是写代码,而是定义问题?
chatgpt·codex·ai agent·chatgpt plus·chatgpt pro·ai coding
呆呆敲代码的小Y6 小时前
10 分钟搞懂 cua:开源 AI 操作电脑基础设施,附 Python 沙箱与 Agent 上手代码
人工智能·python·开源·ai agent·awesome·llm应用·cua
xiezhr7 小时前
期待了很久的DeepSeek多模态视觉模型终于上线了
ai·多模态·ai agent·deepseek·视觉模型·deepseek harness
帅哥的AI自修课8 小时前
Coze 扣子完整实操——从零搭建 Agent 智能体到 OpenClaw 部署
人工智能·知识图谱
努力就够了1 天前
搭建属于自己的 AI 智能体以及多 Agent 协作
springboot·ai agent·ai 智能体·多 agent 协作
Claire_881 天前
从视频字幕到知识图谱:基于大语言模型的自动思维导图生成工具调研
语言模型·音视频·知识图谱
刀锋00011 天前
Java 工程师转 AI Agent 最短路径:用 Spring 的思维理解 Spring AI 2.0(附 LangChain4j 对照)
java·人工智能·spring boot·spring·ai agent
TizzyGoodhealth1 天前
从零到一搭建Spring‑AI原生Tool‑Calling AI Agent|架构对比+完整实战源码+踩坑实录
java·ai·知识库·rag·ai agent·spring ai