先说结论:MemPalace没有任何干货,其好成绩完全来源于ChromaDB数据库本身带的向量检索能力。
下面是我下载源码做的验证:
Q1: 帮我研究下这个代码实现,主要是raw模式的实现
现在我对整个 raw mode 的实现有了全面的了解。以下是详细分析:
MemPalace “Raw Mode” 实现原理
核心理念
Raw mode 不是终端 raw mode,而是一种数据存储与检索策略:直接存储原始对话文本到 ChromaDB,不做任何 LLM 摘要、提取或压缩。这个"笨办法"在 LongMemEval 基准测试上取得了 96.6% R@5 的成绩。
数据流水线(4 步完成)
原始聊天文件 → normalize.py → convo_miner.py → ChromaDB → searcher.py
(输入) (格式统一) (分块+存储) (向量DB) (语义检索)
第 1 步:格式归一化 (normalize.py)
支持 5 种聊天导出格式,统一转为 > user text + assistant text 的转写格式:
| 格式 | 解析函数 |
|---|---|
| Claude Code JSONL | _try_claude_code_jsonl() |
| OpenAI Codex JSONL | _try_codex_jsonl() |
| Claude.ai JSON | _try_claude_ai_json() |
| ChatGPT JSON (mapping tree) | _try_chatgpt_json() |
| Slack JSON | _try_slack_json() |
关键点:已有 > 标记的纯文本直接通过(不做任何处理)。工具调用也被保留(如 [Bash] cmd、[Read path]),但 Read/Edit/Write 的结果被丢弃(因为内容已在代码库中)。
第 2 步:分块 (convo_miner.py)
交换对分块——一个用户问题 + AI回答 = 一个存储单元:
def _chunk_by_exchange(lines):
# 识别 > 开头的用户发言
# 收集后续的 AI 回复(最多 8 行)
# user_turn + ai_response 组成一个 chunk
content = f"{user_turn}\n{ai_response}"
如果文件没有 > 标记,退化为段落分块。
对于项目文件(miner.py),使用固定大小分块:CHUNK_SIZE=800 字符,CHUNK_OVERLAP=100 字符。
第 3 步:逐字存储到 ChromaDB
collection.upsert(
documents=[chunk["content"]], # ← 原文,无任何处理
ids=[drawer_id], # ← SHA256 哈希生成唯一 ID
metadatas=[{
"wing": wing, # 项目/来源标识
"room": chunk_room, # 主题分类(关键词打分)
"source_file": source_file,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "convos",
}],
)
ChromaDB 的默认 embedding 模型会自动把文本转为向量,整个过程零 LLM 调用。
第 4 步:语义检索 (searcher.py)
results = col.query(
query_texts=[query], # 用户查询
n_results=n_results, # 返回 top-N
include=["documents", "metadatas", "distances"],
)
# 返回原文,余弦距离转相似度:similarity = 1 - distance
Raw vs AAAK 对比
| 维度 | Raw Mode | AAAK Mode |
|---|---|---|
| 存储内容 | 原始文本 | 压缩后的缩写方言 |
| LLM 调用 | 0 | 0(规则压缩) |
| LongMemEval R@5 | 96.6% | 84.2% |
| 信息损失 | 无 | 有(压缩导致语义丢失) |
Benchmark 中 raw mode 的实现 (longmemeval_bench.py)
def build_palace_and_retrieve(entry, granularity="session", n_results=50):
# 1. 把每个 session 的所有用户发言拼成一个文档
user_turns = [t["content"] for t in session if t["role"] == "user"]
doc = "\n".join(user_turns) # ← 逐字拼接,零处理
# 2. 直接存入 ChromaDB
collection.add(documents=corpus, ...)
# 3. 语义查询
results = collection.query(query_texts=[query], n_results=50)
架构分层
BaseCollection (抽象接口: add/upsert/query/get/delete/count)
└── ChromaCollection (ChromaDB 适配器)
└── ChromaBackend (工厂,创建 PersistentClient)
└── palace.py (get_collection 统一入口)
为什么 raw mode 效果这么好?
核心洞察是:ChromaDB 的默认 embedding 模型(all-MiniLM-L6-v2)做语义搜索已经足够好了。任何摘要/提取/压缩都是信息损失——原文包含的语义细节最完整,embedding 模型能充分利用这些细节进行匹配。
项目 README 原文:
“MemPalace stores your actual exchanges in ChromaDB without summarization or extraction. The 96.6% LongMemEval result comes from this raw mode.”
Q2:详细总结下palace的构建流程和检索流程
现在我有了完整的信息。以下是详细总结:
Palace 构建流程(完整详解)
一、初始化阶段 (mempalace init <dir>)
1.1 实体检测 (entity_detector.py)
扫描项目文件 → 正则检测人名/项目名 → 用户确认 → 保存 entities.json
1.2 Room 自动检测 (room_detector_local.py)
三层检测逻辑,逐级退化:
| 优先级 | 策略 | 逻辑 |
|---|---|---|
| 1 | 目录结构匹配 | 顶层 + 下一层目录名匹配预定义映射表 (如 frontend/ → room frontend,api/ → room backend) |
| 2 | 文件名模式 | 遍历所有文件名,关键词命中 ≥2 次才创建 room |
| 3 | 兜底 | 只有 general 一个 room |
预定义映射表 FOLDER_ROOM_MAP 有 ~50 个映射,覆盖常见项目结构:
"components" → "frontend"
"api" / "routes" / "models" → "backend"
"docs" → "documentation"
"tests" → "testing"
"config" / "infra" → "configuration"
...
1.3 输出 mempalace.yaml
wing: my_project # 项目名(目录名自动转 snake_case)
rooms:
- name: backend
description: Files from api/
keywords: [backend, api]
- name: frontend
description: Files from components/
keywords: [frontend, components]
- name: general
description: Files that don't fit other rooms
keywords: []
1.4 全局配置 (~/.mempalace/config.json)
{
"palace_path": "~/.mempalace/palace", // ChromaDB 数据目录
"collection_name": "mempalace_drawers", // 唯一的 collection
"topic_wings": ["emotions", "technical", ...],
"hall_keywords": { "emotions": ["scared", "happy", ...], ... }
}
二、Mining 阶段(构建 Palace 数据)
2.1 项目文件 Mining (mempalace mine <dir>,mode=projects)
scan_project()
├── os.walk 遍历目录
├── 跳过 SKIP_DIRS (.git, node_modules, __pycache__ 等 35 个)
├── 只保留 READABLE_EXTENSIONS (.py, .js, .md, .json 等 20 个)
├── 多级 .gitignore 匹配(支持 negation、anchored、dir_only、**)
└── 跳过 symlinks 和 >10MB 文件
process_file()
├── file_already_mined() → 检查 source_mtime 避免重复
├── filepath.read_text() → 原文读入(UTF-8)
├── detect_room() → 路由到正确的 room
├── chunk_text() → 固定大小分块
└── add_drawer() → 逐块存入 ChromaDB
Room 路由 (detect_room) 三级优先:
1. 文件路径的目录部分匹配 room name/keywords
2. 文件名匹配 room name
3. 文件内容前 2000 字符中 room keywords 出现次数打分
4. 兜底 → "general"
文本分块 (chunk_text):
CHUNK_SIZE = 800 字符
CHUNK_OVERLAP = 100 字符
MIN_CHUNK_SIZE = 50 字符
分块策略:
1. 尝试在段落边界 (\n\n) 切割
2. 退化到行边界 (\n) 切割
3. 最后硬切
2.2 对话 Mining (mempalace mine <dir> --mode convos)
scan_convos()
└── 只保留 .txt, .md, .json, .jsonl
normalize() ← 5 种格式统一化
├── 已有 > 标记 → 直通
├── Claude Code JSONL → 提取 human/assistant turns + tool_use 格式化
├── Codex JSONL → 提取 user_message/agent_message
├── Claude.ai JSON → 提取 user/assistant messages
├── ChatGPT JSON → 遍历 mapping tree 提取
└── Slack JSON → 交替标记 user/assistant
chunk_exchanges()
├── 检测 > 标记 ≥ 3 → 交换对分块 (Q+A = 一个 chunk)
└── 否则 → 段落分块 (fallback)
mine_convos()
├── detect_convo_room() → 关键词打分分类 (technical/architecture/planning/decisions/problems)
└── collection.upsert() → 逐 chunk 存入
2.3 存储层(ChromaDB 三层抽象)
BaseCollection (抽象接口)
│ add(), upsert(), query(), get(), delete(), count()
│
├── ChromaCollection (适配器 → 直接代理到 ChromaDB collection)
│
└── ChromaBackend (工厂)
├── _fix_blob_seq_ids() → 修复 0.6.x→1.5.x 迁移 bug
├── PersistentClient(path=palace_path)
└── get_or_create_collection("mempalace_drawers")
每个 drawer 的完整 metadata:
{
"wing": "my_project", # 项目/来源
"room": "backend", # 主题分类
"source_file": "/path/to/file", # 原始文件路径
"chunk_index": 3, # 分块序号
"added_by": "mempalace", # 谁添加的
"filed_at": "2026-04-13T...", # 存入时间
"source_mtime": 1712345678.0, # 文件修改时间(仅项目 mining)
"ingest_mode": "convos", # 仅对话 mining
"extract_mode": "exchange", # 仅对话 mining
}
Drawer ID 生成规则:
drawer_id = f"drawer_{wing}_{room}_{sha256(source_file + chunk_index)[:24]}"
三、Palace 拓扑结构
Palace (ChromaDB PersistentClient)
└── Collection: "mempalace_drawers" ← 唯一的 collection
│
├── Wing: "my_app"
│ ├── Room: "backend" ← N 个 drawers
│ ├── Room: "frontend" ← N 个 drawers
│ └── Room: "general" ← N 个 drawers
│
├── Wing: "claude_sessions"
│ ├── Room: "technical" ← N 个 drawers
│ ├── Room: "decisions" ← N 个 drawers
│ └── Room: "planning" ← N 个 drawers
│
└── Wing: "chatgpt_chats"
└── Room: "general" ← N 个 drawers
Palace Graph (palace_graph.py) 在此之上构建导航图:
- Node = room(跨 wing 聚合同名 room)
- Edge (Tunnel) = 同一个 room 出现在 2+ 个 wing 中
- 支持 BFS 遍历找关联主题
检索流程(完整详解)
四、4 层记忆栈 (layers.py)
┌─────────────────────────────────────────────────────┐
│ Layer 0: Identity (~100 tokens) │
│ ← ~/.mempalace/identity.txt (用户手写的身份描述) │
│ 始终加载 │
├─────────────────────────────────────────────────────┤
│ Layer 1: Essential Story (~500-800 tokens) │
│ ← 从 ChromaDB 拉取 top 15 drawers,按重要性排序 │
│ ← 分 room 分组,每条截断 200 字符 │
│ ← 总量 hard cap 3200 字符 │
│ 始终加载(wake-up 时生成) │
├─────────────────────────────────────────────────────┤
│ Layer 2: On-Demand (~200-500 tokens/次) │
│ ← 按 wing/room 过滤的 col.get() │
│ ← 当对话中提到特定项目/主题时按需加载 │
├─────────────────────────────────────────────────────┤
│ Layer 3: Deep Search (无限深度) │
│ ← ChromaDB 全量语义搜索 (cosine distance) │
│ ← 支持 wing/room/max_distance 过滤 │
│ ← 这是 raw mode 的核心 │
└─────────────────────────────────────────────────────┘
五、CLI 检索 (mempalace search "query")
searcher.search(query, palace_path, wing=None, room=None, n_results=5)
│
├── get_collection(palace_path, create=False) → ChromaDB collection
│
├── build_where_filter(wing, room)
│ ├── wing + room → {"$and": [{"wing": wing}, {"room": room}]}
│ ├── 仅 wing → {"wing": wing}
│ ├── 仅 room → {"room": room}
│ └── 都没有 → {}(全库搜索)
│
├── col.query(
│ query_texts=[query], # ChromaDB 自动 embed 查询文本
│ n_results=5,
│ include=["documents", "metadatas", "distances"],
│ where=where, # 可选过滤
│ )
│
└── 输出:
[1] my_app / backend
Source: api_routes.py
Match: 0.847
(drawer 原文,逐行打印)
六、MCP Server 检索 (mempalace_search tool)
tool_search(query, limit=5, wing=None, room=None, max_distance=1.5)
│
├── sanitize_query(query) → 防注入清洗(去除 system prompt 污染)
│
├── search_memories(...) → 返回 dict 而非打印
│ ├── col.query(...) → ChromaDB 语义搜索
│ ├── 距离过滤: dist > max_distance → 丢弃
│ └── 返回 {
│ "query": "...",
│ "results": [
│ {"text": "原文", "wing": "...", "room": "...",
│ "similarity": 0.847, "distance": 0.153},
│ ...
│ ]
│ }
│
└── 额外安全: WAL 日志记录所有写操作,查询清洗防 prompt 注入
七、Graph 检索(高级导航)
# 从某个 room 出发,BFS 遍历找关联主题
tool_traverse_graph(start_room="chromadb-setup", max_hops=2)
│
├── build_graph() → 从所有 drawer metadata 构建 room 图
│ ├── Node: room → {wings, halls, count, dates}
│ └── Edge: 同名 room 出现在 2+ wings → tunnel
│
└── BFS: 找到所有 shared-wing 连接的 room,按 hop_distance 排序
# 找到连接两个 wing 的 "隧道" room
tool_find_tunnels(wing_a="my_app", wing_b="claude_sessions")
└── 返回同时出现在两个 wing 中的 room 列表
八、检索模式对比
| 检索方式 | 入口 | 是否用到 wing/room | 底层方法 |
|---|---|---|---|
| CLI search | mempalace search | 可选过滤 | col.query() 语义搜索 |
| MCP search | mempalace_search tool | 可选过滤 | col.query() + 查询清洗 |
| L1 wake-up | mempalace wake-up | 可选 wing 过滤 | col.get() 全量拉取 → 重要性排序 |
| L2 recall | MCP mempalace_search | 必须指定 wing/room | col.get() 条件过滤 |
| L3 deep | MCP mempalace_search | 可选 | col.query() 语义搜索 |
| Graph traverse | MCP mempalace_traverse_graph | N/A(基于 room 图) | 从 metadata 构建图 → BFS |
| Duplicate check | MCP mempalace_check_duplicate | 无 | col.query() 相似度 ≥ 0.9 |
总结:Raw Mode 的精髓
整个系统的核心设计理念可以用一句话概括:
逐字存储原文 → ChromaDB 默认 embedding → 余弦语义搜索 → 返回原文
Palace 结构(wing/room/drawer)提供的是组织和过滤能力,但不参与语义匹配。检索时 wing/room 过滤是可选的——默认全库搜索反而效果最好(96.6% vs room 过滤的 89.4%)。这解释了为什么 benchmark 中 raw mode 干脆不用任何 palace 结构,而产品代码保留 palace 结构主要是为了用户体验(按项目组织、按主题浏览)。
Q3:raw模式那么好的效果用了sqlite吗
直接回答:96.6% 的成绩纯靠 ChromaDB 向量检索,没有用 SQLite。
Benchmark Raw Mode 的实现
回看 longmemeval_bench.py 中 raw mode 的核心代码:
# 用的是内存模式,连 PersistentClient 都不是
_bench_client = chromadb.Client() # 纯内存
def build_palace_and_retrieve(entry, ...):
collection = _bench_client.create_collection(name)
# 1. 原文直接存入(ChromaDB 自动用 all-MiniLM-L6-v2 生成 embedding)
collection.add(documents=corpus, ids=[...], metadatas=[...])
# 2. 语义查询(ChromaDB 内部做 HNSW 近邻搜索)
results = collection.query(query_texts=[query], n_results=50)
# 完了。没有任何 SQLite 操作。
整条链路只有两步: 文本进去 → 向量搜出来。
SQLite 在项目中的存在
SQLite 在这个项目里有两个角色,但都不参与 raw mode 检索:
| SQLite 用途 | 文件 | 与 raw mode 的关系 |
|---|---|---|
| ChromaDB 内部元数据存储 | ~/.mempalace/palace/chroma.sqlite3 | ChromaDB 用它存 metadata、ID 映射等。但向量检索走的是 HNSW 索引,不走 SQL |
| 知识图谱 | ~/.mempalace/knowledge_graph.sqlite3 | 完全独立的功能(knowledge_graph.py),存实体关系、时间线。与 raw mode 无关 |
ChromaDB 内部原理
ChromaDB 拿到文本后的处理流程:
原文 "why did we switch to GraphQL"
│
▼
all-MiniLM-L6-v2 (本地 embedding 模型,384 维)
│
▼
HNSW 向量索引 (hnswlib,近似最近邻搜索)
│
▼
返回 top-N 结果 + cosine distance
- Embedding:ChromaDB 默认用
all-MiniLM-L6-v2,一个 22M 参数的小模型,本地运行 - 索引:HNSW(Hierarchical Navigable Small World),一种高效的 ANN 算法
- 距离度量:cosine distance(
hnsw:space=cosine) - SQLite 的角色:仅存 metadata(wing、room、source_file 等),不参与向量相似度计算
所以 raw mode 为什么效果好
答案很朴素——什么都没做就是最好的做法:
- 原文包含完整语义信号 → embedding 质量最高
- 查询也是原文 → 与存储的 embedding 在同一个语义空间
- 没有任何中间处理(摘要/提取/压缩)→ 零信息损失
反观 AAAK 模式(84.2%):压缩后的文本(ALC→♡JOR | 2D(kids))和用户的自然语言查询("Who is Alice's partner?")在 embedding 空间中的距离变大了,所以召回率下降。
Q4:在什么情况下会触发sqlite的检索?
答案很明确了:
SQLite 检索的触发方式
SQLite 知识图谱不会被自动触发。 它完全依赖 AI 通过 MCP 工具主动调用。
触发机制
MCP server 在 tool_status() 返回的协议指令(PALACE_PROTOCOL)里教 AI 什么时候该调:
IMPORTANT — MemPalace Memory Protocol:
1. ON WAKE-UP: Call mempalace_status ...
2. BEFORE RESPONDING about any person, project, or past event:
call mempalace_kg_query or mempalace_search FIRST. Never guess — verify.
3. IF UNSURE about a fact (name, gender, age, relationship):
say "let me check" and query the palace.
4. AFTER EACH SESSION: call mempalace_diary_write ...
5. WHEN FACTS CHANGE: call mempalace_kg_invalidate on the old fact,
mempalace_kg_add for the new one.
所以触发链路是:
用户提到某人/某事
│
▼
AI 看到 PALACE_PROTOCOL 的指令
│ "BEFORE RESPONDING about any person... call mempalace_kg_query FIRST"
│
▼
AI 主动调用 MCP tool
│
├── mempalace_kg_query(entity="Max") → SQLite SELECT
├── mempalace_kg_timeline(entity="Max") → SQLite SELECT
├── mempalace_kg_stats() → SQLite SELECT
│
│ (对话中发现新事实时)
├── mempalace_kg_add(s, p, o) → SQLite INSERT
└── mempalace_kg_invalidate(s, p, o) → SQLite UPDATE
5 个 KG 工具对应的 SQL 操作
| MCP 工具 | SQLite 操作 | 触发场景 |
|---|---|---|
mempalace_kg_query | SELECT ... FROM triples JOIN entities WHERE subject=? | AI 需要核实关于某人/某事的事实 |
mempalace_kg_add | INSERT INTO entities + INSERT INTO triples | 对话中出现了新的事实(“Max 开始学钢琴了”) |
mempalace_kg_invalidate | UPDATE triples SET valid_to=? WHERE ... | 旧事实不再成立(“Max 的伤好了”) |
mempalace_kg_timeline | SELECT ... ORDER BY valid_from ASC | AI 需要看某人的完整时间线 |
mempalace_kg_stats | SELECT COUNT(*) FROM entities/triples | 查看知识图谱概况 |
关键点:没有自动触发路径
跟 ChromaDB 不同(mine 命令批量写入、search 命令批量查询),知识图谱完全没有批量导入机制。所有数据都是 AI 在对话过程中一条条添加的。
唯一的例外是 seed_from_entity_facts()——用于从 entity_detector.py 检测到的实体初始化知识图谱,但这也是手动调用的初始化操作,不是自动触发。
一句话总结
SQLite 知识图谱是一个"AI 自觉遵守协议才会用的工具"。 系统通过在 mempalace_status 返回的协议文本中教育 AI,让 AI 在涉及人、项目、事实时主动调用 mempalace_kg_query 先查再答。如果 AI 不遵守协议,知识图谱就不会被触发——这是一个纯靠 prompt engineering 驱动的机制。
Q5: sqlite的关系能自动更新吗?怎么实现的?
确认完毕。
答案:不能自动更新。完全靠 AI 手动维护。
全项目中写入 SQLite 知识图谱的路径只有 3 条
| 路径 | 触发方式 | 代码位置 |
|---|---|---|
mempalace_kg_add MCP 工具 | AI 在对话中主动调用 | mcp_server.py → _kg.add_triple() |
mempalace_kg_invalidate MCP 工具 | AI 在对话中主动调用 | mcp_server.py → _kg.invalidate() |
seed_from_entity_facts() | 初始化时手动调用一次 | knowledge_graph.py |
没有任何自动提取、自动同步、自动更新的代码。
我搜遍了整个 mempalace/ 目录,_kg.add_triple 和 _kg.invalidate 只在 mcp_server.py 中被调用,而且只通过 MCP 工具暴露。convo_miner.py、miner.py、general_extractor.py、hooks_cli.py 这些自动化流程都只写 ChromaDB,不碰 SQLite 知识图谱。
具体的"自动更新"缺失在哪
你可能会期望这样的流程存在:
❌ 不存在的流程:
mine 对话 → 检测到 "Max 开始学钢琴了"
→ 自动调 kg.add_triple("Max", "does", "piano")
→ 自动调 kg.invalidate("Max", "does", "swimming")
实际上 general_extractor.py 能用正则提取 5 种记忆类型(decisions、preferences、milestones、problems、emotional),但提取结果只存入 ChromaDB(作为 drawer),不会写入知识图谱。
它怎么"假装"自动更新
靠 PALACE_PROTOCOL 里的 prompt 指令让 AI 自觉维护:
5. WHEN FACTS CHANGE: call mempalace_kg_invalidate on the old fact,
mempalace_kg_add for the new one.
典型场景:
用户: "Max 不游泳了,改学钢琴了"
AI(如果遵守协议):
1. 调 mempalace_kg_invalidate("Max", "does", "swimming", ended="2026-04")
2. 调 mempalace_kg_add("Max", "does", "piano", valid_from="2026-04")
3. 回复用户
AI(如果不遵守协议):
1. 直接回复 "好的,知道了"
→ 知识图谱不更新,下次问 Max 爱好时还是返回 swimming
总结
知识图谱是这个项目中最依赖 AI 主动性的部分。ChromaDB 有 mine 命令做批量导入、有 hooks 做定时保存,是半自动的。但知识图谱没有任何自动化管道——它的数据质量完全取决于 AI 是否忠实执行协议。这也是为什么项目文档明确写道:
“Storage is not memory — but storage + this protocol = memory.”
Q7:palace结构是在chromaDB里还是在SQLite里?
Palace 结构存在 ChromaDB 里,以 metadata 的形式。 没有单独的结构定义。
具体存储方式
ChromaDB 只有一个 collection:mempalace_drawers。每个 document(drawer)自带 wing/room 信息:
collection.upsert(
documents=["We decided to use GraphQL..."], # 原文
ids=["drawer_my_app_backend_abc123"], # ID 里编码了 wing+room
metadatas=[{
"wing": "my_app", # ← palace 结构就靠这个
"room": "backend", # ← 和这个
"source_file": "...",
"filed_at": "...",
}],
)
没有 wings 表、没有 rooms 表、没有任何 schema 定义。 Wing 和 room 就是 metadata 里的两个字符串字段。
结构是"涌现"出来的
存入 100 个 drawer,每个带 wing="my_app", room="backend"
存入 50 个 drawer,每个带 wing="my_app", room="frontend"
存入 200 个 drawer,每个带 wing="chat_sessions", room="technical"
→ 此时 palace "有" 2 个 wing、3 个 room
查看 palace 结构时,是遍历所有 drawer 的 metadata 聚合出来的:
# mcp_server.py — tool_status()
all_meta = _get_cached_metadata(col) # 拉全部 metadata
for m in all_meta:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
wings[w] = wings.get(w, 0) + 1 # 计数聚合
rooms[r] = rooms.get(r, 0) + 1
# tool_get_taxonomy()
for m in all_meta:
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1 # 二级聚合
mempalace.yaml 只是 mining 时的配置
# 项目目录下的 mempalace.yaml
wing: my_app
rooms:
- name: backend
keywords: [backend, api]
- name: frontend
keywords: [frontend, components]
这个文件只在 mempalace mine 时读取,用于决定每个文件归到哪个 room。一旦 drawer 存入 ChromaDB,这个 yaml 就不再被引用了。删掉 yaml 也不影响已有数据的查询。
对比两个存储
| ChromaDB | SQLite 知识图谱 | |
|---|---|---|
| Palace 结构 | ✅ metadata 里的 wing/room 字段 | ❌ 无关 |
| 结构定义方式 | 无显式定义,从数据聚合 | 有显式的 entities/triples 表 |
| 新增 wing/room | 存一个新 metadata 值就行 | N/A |
| 删除 wing/room | 删掉所有相关 drawer 就消失了 | N/A |
一句话:palace 结构是 ChromaDB metadata 的副产品,不是预先定义的 schema。
Q8: sqlite和chromaDB不会被同时使用,对吗?
基本正确,但要分两个层面说:
代码层面:完全独立,不会联合查询
mempalace_kg_query("Max") → 只查 SQLite,不碰 ChromaDB
mempalace_search("Max 近况") → 只查 ChromaDB,不碰 SQLite
没有任何一个函数同时查两个存储。它们是两条完全独立的代码路径。
AI 使用层面:会在同一个对话中先后调用
协议里写的是:
BEFORE RESPONDING about any person… call mempalace_kg_query or mempalace_search FIRST
实际场景中 AI 可能这样做:
用户: "Max 最近怎么样?"
AI 内部决策:
1. 先调 mempalace_kg_query("Max") ← SQLite
→ 得到结构化事实: 11岁, Alice的儿子, 喜欢象棋
2. 再调 mempalace_search("Max recent") ← ChromaDB
→ 得到相关原文对话片段
3. 综合两个结果回答用户
但这是 AI 发起的两次独立调用,不是系统自动联合查询。
有一个"弱关联"设计
知识图谱的 triples 表有个 source_closet 字段:
triples (
...
source_closet TEXT, -- 关联到 ChromaDB 中的 drawer ID
...
)
kg.add_triple("Max", "loves", "chess",
source_closet="drawer_sessions_technical_abc123")
# ↑ 这条事实的来源是哪个 ChromaDB drawer
但这只是一个记录来源的指针,代码中没有任何地方根据这个字段去反查 ChromaDB。它的作用是审计溯源——如果某条事实有争议,可以手动找到原始对话验证。
总结
| 维度 | 是否同时使用 |
|---|---|
| 单次函数调用 | ❌ 不会 |
| 单次对话中 | ✅ AI 可能先后调用两者 |
| 数据关联 | 只有 source_closet 单向指针,无联合查询 |
| 自动触发 | ❌ 都需要 AI 主动决定调哪个 |
它们是互补关系而非协作关系——ChromaDB 做模糊搜索,SQLite 做精确查询,但由 AI 自己判断该用哪个。
Q9: AAAK压缩具体是怎么做的?
AAAK 压缩的完整实现
核心定义
AAAK 是有损摘要(lossy summarization),不是无损压缩。原文不可恢复。它把自然语言文本提取为一行结构化符号表示。
压缩流水线(compress() 方法)
输入一段自然语言,经过 5 个提取步骤,输出一行 AAAK:
原文: "We decided to use GraphQL instead of REST because Alice said
the subscription model fits our real-time dashboard better.
Max was excited about it."
↓ 5 步提取 ↓
输出: my_app|backend|2026-04|chat_session
0:ALC+MAX|graphql_subscription_dashboard|"decided to use GraphQL instead of REST"|determ+excite|DECISION+TECHNICAL
5 个提取步骤详解
步骤 1:实体检测 (_detect_entities_in_text)
# 优先:查已知实体映射表
entities = {"Alice": "ALC", "Max": "MAX", "Jordan": "JOR"}
# 如果 "Alice" 出现在文本中 → "ALC"
# 兜底:取首字母大写单词的前 3 个字符
# "GraphQL" → "GRA"(如果不在映射表中)
输出:ALC+MAX(最多 3 个)
步骤 2:主题提取 (_extract_topics)
# 1. 分词,去掉 ~300 个停用词 (the, is, are, to, of...)
# 2. 统计词频
# 3. 加分项:
# - 首字母大写(专有名词)+2
# - 含下划线/连字符/驼峰(技术术语)+2
# 4. 按频率排序,取 top 3
输出:graphql_subscription_dashboard
步骤 3:关键句提取 (_extract_key_sentence)
# 1. 按 .!?\n 切分句子
# 2. 给每句打分:
# - 含决策词 (decided, because, switched, reason...) → +2/词
# - 短句 <80 字符 → +1,<40 字符 → +2
# - 长句 >150 字符 → -2
# 3. 取得分最高的句子,截断到 55 字符
输出:"decided to use GraphQL instead of REST"
步骤 4:情绪检测 (_detect_emotions)
# 关键词 → 情绪代码映射(~30 个)
_EMOTION_SIGNALS = {
"decided": "determ", # 决心
"excited": "excite", # 兴奋
"worried": "anx", # 焦虑
"love": "love", # 爱
"frustrated": "frust", # 沮丧
...
}
# 扫描文本,匹配到哪个关键词就输出对应代码
# 最多 3 个
输出:determ+excite
步骤 5:标志检测 (_detect_flags)
# 关键词 → 标志映射(~30 个)
_FLAG_SIGNALS = {
"decided": "DECISION", # 做了决策
"instead of": "DECISION",
"because": "DECISION",
"created": "ORIGIN", # 创造了什么
"breakthrough":"PIVOT", # 转折点
"api": "TECHNICAL", # 技术细节
"framework": "TECHNICAL",
"core": "CORE", # 核心信念
...
}
# 最多 3 个
输出:DECISION+TECHNICAL
最终输出格式
# 有 metadata 时带 header:
{wing}|{room}|{date}|{source_file_stem}
{zid}:{entities}|{topics}|"{key_quote}"|{emotions}|{flags}
# 无 metadata 时:
0:{entities}|{topics}|"{key_quote}"|{emotions}|{flags}
完整的 AAAK 符号体系
| 符号类型 | 格式 | 例子 |
|---|---|---|
| 实体 | 3 字母大写 | ALC=Alice, MAX=Max |
| 情绪 | 3-7 字母小写 | vul=脆弱, joy=喜悦, anx=焦虑 |
| 标志 | 全大写单词 | DECISION, ORIGIN, PIVOT, CORE |
| 主题 | 下划线连接 | graphql_subscription_dashboard |
| 引用 | 双引号包裹 | "decided to use GraphQL" |
| 分隔符 | ` | ` |
| 连接符 | + | 同类型多值连接 |
| 隧道 | T:zid1<->zid2 | 两个 zettel 之间的关联 |
| 情感弧 | ARC:e1->e2->e3 | 整个文件的情感走向 |
具体压缩效果
原文(~200 tokens):
"We had a long discussion about the database architecture. Alice
suggested switching from PostgreSQL to MongoDB for the user profiles
service because the schema keeps changing. Max was worried about
losing ACID guarantees but eventually agreed after Alice showed the
benchmarks. We decided to keep PostgreSQL for the billing service."
AAAK(~15 tokens):
0:ALC+MAX|postgresql_mongodb_architecture|"decided to keep PostgreSQL for the billing"|anx+determ|DECISION+TECHNICAL
~13x 压缩比,但丢失了大量细节(谁提议的、为什么担心、基准测试结果等)。
为什么 AAAK 在 benchmark 中表现差(84.2% vs 96.6%)
原因就在压缩过程中:
原文: "Max likes swimming on Tuesday afternoons at the community pool"
AAAK: 0:MAX|swimming_community|"Max likes swimming on Tuesday"|joy|
查询: "Where does Max swim?"
AAAK 保留了 “swimming” 和 “community”,但 all-MiniLM-L6-v2 对 0:MAX|swimming_community|... 这种管道分隔格式的 embedding 质量不如对原始自然语言的 embedding。embedding 模型是在自然语言上训练的,不是在符号格式上训练的——这就是信息损失导致召回率下降的根本原因。
两种压缩入口
| 入口 | 用于 | 差异 |
|---|---|---|
compress(text) | 纯文本 → AAAK | 用正则/关键词启发式提取,零 LLM |
encode_file(zettel_json) | 预处理好的 zettel JSON → AAAK | 直接读已有的 people、topics、emotional_weight 等字段 |
两条路径最终都输出同样的 | 分隔格式,只是数据来源不同。整个过程完全不调用任何 LLM——全是正则匹配 + 关键词打分 + 词频统计。
Q10: 对话构建room时,detect_convo_room 的关键词打分,是怎么做的?
我之前已经读过这段代码,直接解释:
detect_convo_room 完整实现
# convo_miner.py
TOPIC_KEYWORDS = {
"technical": [
"code", "python", "function", "bug", "error",
"api", "database", "server", "deploy", "git",
"test", "debug", "refactor",
],
"architecture": [
"architecture", "design", "pattern", "structure",
"schema", "interface", "module", "component",
"service", "layer",
],
"planning": [
"plan", "roadmap", "milestone", "deadline",
"priority", "sprint", "backlog", "scope",
"requirement", "spec",
],
"decisions": [
"decided", "chose", "picked", "switched",
"migrated", "replaced", "trade-off",
"alternative", "option", "approach",
],
"problems": [
"problem", "issue", "broken", "failed", "crash",
"stuck", "workaround", "fix", "solved", "resolved",
],
}
def detect_convo_room(content: str) -> str:
content_lower = content[:3000].lower() # 只看前 3000 字符
scores = {}
for room, keywords in TOPIC_KEYWORDS.items():
score = sum(1 for kw in keywords if kw in content_lower)
if score > 0:
scores[room] = score
if scores:
return max(scores, key=scores.get) # 得分最高的 room
return "general" # 全没命中 → general
逻辑极其简单
就是数关键词出现了几个(不是几次)。
用一个具体例子走一遍:
输入文本(前 3000 字符):
"We had a bug in the API server. The database schema was wrong.
I decided to switch from REST to GraphQL. The architecture
pattern needs refactoring. We fixed the deploy issue."
逐 room 打分:
technical: "bug"✓ "api"✓ "server"✓ "database"✓ "deploy"✓ "refactor"✓ → 6
architecture: "architecture"✓ "schema"✓ "pattern"✓ → 3
planning: (无命中) → 0
decisions: "decided"✓ "switched"✓ → 2 (注意: "switch" 不匹配 "switched",但 "switched" 包含子串匹配不了...
等等,用的是 `kw in content_lower`,
所以 "switched" 中 `in` 查找 "switched" ≠ "switch",
但 "decided" 在文本中 → ✓,实际只命中 "decided") → 1
problems: "bug"✓ "fixed"✗("fix"≠"fixed") "issue"✓ → 2
注意这里的一个细节——用的是 kw in content_lower(子串匹配),不是全词匹配:
"fix" in "we fixed the issue" → True ✓("fix" 是 "fixed" 的子串)
"switched" 不在 keywords 里,但
"switch" in "I switched to" → True ✓("switch" 是 "switched" 的子串)
"test" in "testing the system" → True ✓("test" 是 "testing" 的子串)
所以实际得分:
technical: "bug"✓ "api"✓ "server"✓ "database"✓ "deploy"✓ → 5 + "test"看情况
architecture: "architecture"✓ "schema"✓ "pattern"✓ → 3
decisions: "decided"✓ "switch"✓ → 2 (子串匹配到了)
problems: "problem"✗ "issue"✓ "fix"✓ → 2
最终:technical 得分最高 → room = “technical”
关键特征
| 特征 | 说明 |
|---|---|
| 匹配方式 | kw in text,子串包含,不是正则也不是全词 |
| 计分方式 | 每个关键词最多贡献 1 分(不计出现次数) |
| 范围限制 | 只看前 3000 字符(content[:3000]) |
| Room 数量 | 固定 5 个 + 兜底 general,共 6 种 |
| 每 Room 关键词 | 10-13 个 |
| 平局处理 | max(scores, key=scores.get) — 取第一个最大值(dict 插入序决定) |
与项目文件 mining 的 detect_room 对比
| 维度 | 对话 detect_convo_room | 项目 detect_room |
|---|---|---|
| Room 来源 | 硬编码 5 个 | 从 mempalace.yaml 读取(用户自定义) |
| 匹配优先级 | 只有关键词打分一种 | 路径 → 文件名 → 内容关键词,三级 |
| 计分方式 | 每词 0/1 | content.count(kw) 计出现次数 |
| 扫描范围 | 前 3000 字符 | 前 2000 字符 |
注意项目 mining 的打分是 content_lower.count(kw.lower())(计出现次数),而对话 mining 是 1 for kw in keywords if kw in content_lower(只看有没有)。这是两套不同的计分逻辑。
Q11: raw mode 本质上就是用了一个向量召回,为什么能拿第一的分数?这么简单的逻辑,其他方案就搞不定吗
这个问题问到点子上了。答案是两方面都有——raw mode 确实利用了一个被忽视的信息论优势,但 LongMemEval 的评测设计也给了它显著的"顺风"。拆开讲:
一、为什么 raw 能打:信息无损优势
核心论点很简单:其他系统在存储阶段就丢失了信息。
Mem0 的流程:
对话 → LLM 提取 → "用户偏好 PostgreSQL" → 存储
丢失了:为什么偏好、比较了什么替代方案、在什么场景下
MemPal raw 的流程:
对话 → 原封不动存入 ChromaDB
保留了:所有上下文、所有词汇、所有细节
当 benchmark 提问 “用户为什么选择 PostgreSQL” 时:
- Mem0 存的是
"用户偏好 PostgreSQL"→ 语义上和问题有重叠,但缺少 “为什么” - Raw 存的是原文
"我比较了 Postgres 和 MySQL,最后选了 Postgres 因为..."→ 问题里的关键词直接命中原文
这不是 raw 有多聪明,而是其他系统在存储环节做了有损压缩。 LLM 提取事实 = 有损编码,提取错了就找不回来了。
二、但 LongMemEval 的设计,确实对 raw 极其友好
这才是核心。从代码里能看出几个关键的评测条件:
条件 1:每个问题有独立的小 haystack
# 第 183 行
sessions = entry["haystack_sessions"] # 每个问题自带 haystack
每个问题只在自己的 haystack 里搜索,不是搜一个全局的巨大语料库。LongMemEval 的 haystack 大约是 ~50 个 session。
这意味着你只要在 50 个文档里找到正确的那 1 个。这和真实场景(几千到几万条记忆里搜索)完全不同。
条件 2:R@5 指标极其宽松
# 第 77 行
recall_any = float(any(cid in top_k_ids for cid in correct_ids))
R@5 的含义是:top 5 个结果里只要有任意 1 个命中就算对。
50 个 session 里取 top 5 = 覆盖了 10% 的 haystack。换句话说,即使排序质量很差,只要正确答案没被排到最后 90%,就算成功。对向量检索来说,这几乎是"白送分"的难度。
条件 3:问题与原文的词汇高度重叠
LongMemEval 的问题是根据真实对话生成的,问题里的关键词大概率直接出现在原文里。比如:
- 问题问 “yoga classes” → 原文里大概率有 “yoga” 这个词
- 问题问 “Dr. Chen” → 原文里一定有 “Dr. Chen”
这是 embedding 模型最擅长的场景——词汇重叠 = 高余弦相似度。在这种条件下,all-MiniLM-L6-v2 这种轻量模型就够了。
条件 4:粒度是 session 级别
# 第 188-195 行
if granularity == "session":
user_turns = [t["content"] for t in session if t["role"] == "user"]
doc = "\n".join(user_turns) # 整个 session 拼成一个文档
一个 session 的完整用户对话被拼成一个长文档。文档越长,包含的关键词越多,被问题命中的概率越高。这进一步降低了检索难度。
三、真正难的评测上,raw 的表现大幅下降
MemPal 自己的其他 benchmark 数据证明了这一点:
| 评测集 | Raw 表现 | 为什么不同 |
|---|---|---|
| LongMemEval | 96.6% R@5 | ~50 session,R@5,词汇重叠高 |
| LoCoMo (top-10) | 60.3% R@10 | 多跳推理、说话者混淆、时序推断 |
| MemBench noisy | 43.4% R@5 | 故意加入干扰信息,语义噪声大 |
| MemBench conditional | 57.3% R@5 | 需要条件推理,检索不够 |
LoCoMo 的 60.3% 才是 raw 的真实水平线。 当问题需要跨 session 推理、需要区分说话者、需要理解时间关系时,纯向量检索就不够了。
四、那其他系统为什么不先试 raw?
这其实是 BENCHMARKS.md 里作者自己说的核心发现:
Nobody published this result because nobody tried the simple thing and measured it properly.
原因有几个:
- 假设偏差:整个领域默认 “AI 记忆需要 LLM 来管理”,没人认为存原文就够用,所以根本没人测
- 产品目标不同:Mem0、Mastra 是给生产环境设计的——它们要处理几年的对话数据、要控制存储成本、要实时响应。存原文在这些场景下不现实(存储膨胀、检索慢)
- 评测缺失:在 MemPal 之前,没有人在 LongMemEval 上跑过纯向量检索的 baseline,所以也没有人知道这个 baseline 有多强
- 学术惯性:发论文需要 “新方法”,报告 “我们什么都没做,直接存原文” 发不了 paper
五、总结
| 因素 | 贡献 |
|---|---|
| 信息无损 | ✅ 真实优势——有损提取确实会丢失关键语义 |
| 小 haystack(~50 session) | 🎯 评测设计红利——真实场景下几千条记忆时效果会降 |
| R@5 宽松指标 | 🎯 评测设计红利——10% 覆盖率足以命中 |
| 词汇高度重叠 | 🎯 评测设计红利——现实中用户提问更模糊 |
| 其他系统没试过这个 baseline | 🤷 行业盲区 |
所以结论是:raw mode 的 96.6% 既不是纯靠实力,也不是纯靠评测集简单。它是一个在特定评测条件下被放大了的真实优势。 作者在 BENCHMARKS.md 里其实很诚实地承认了这一点——他们自己做的 LoCoMo 和 MemBench 结果就打了自己的脸,raw 在复杂场景下直接跌到 43%-60%。
真正值得关注的反而是这个发现背后的方法论意义:在加复杂结构之前,先测 baseline。整个 AI memory 领域在没测过最简单方案的情况下就一路堆复杂度,这才是问题所在。
Q12: BENCHMARKS.md 里有很多评测集和结果,有些显得 memPalace 很厉害,但从原理上来看,它并不厉害,为什么会有这种体验上差异呢?
这个观察非常犀利。BENCHMARKS.md 本身写得很诚实(作者主动承认了多处问题),但文档的结构和叙事手法确实制造了一种"这个系统很厉害"的认知。让我拆解这种体验差异的来源。
一、叙事结构:先给结论,后给限定
文档的信息排列顺序是:
第 1-60 行 → 核心发现 + 对比表(MemPal 第一名)
第 60-160 行 → 多个 benchmark 的亮眼数字
第 160-300 行 → 改进路径 96.6% → 100%
第 450-500 行 → 诚实申报(teaching to the test、LoCoMo 结构性问题)
人的注意力是衰减的。 96.6%、100%、“beats Mem0 by 2x” 在前半部分反复出现,而 “这是 teaching to the test”、“honest score 是 60.3%” 这些限定条件埋在第 450 行之后。大多数读者读到一半就已经形成了 “这个系统很强” 的印象。
二、对比对象的选择偏差
BENCHMARKS.md 的对比表:
| 系统 | 分数 | 作者给的印象 |
|---|---|---|
| MemPal raw | 96.6% | 🥇 |
| Mastra | 94.87% | 低于我们 |
| Mem0 | 30-45% | 远低于我们 |
但这个对比有几个问题:
1)Mem0 的 30-45% 来自 ConvoMem benchmark,不是 LongMemEval
文档在第 73 行把 Mem0 的 ConvoMem 成绩和 MemPal 的 ConvoMem 成绩放在一起比(92.9% vs 30-45%),看起来像是碾压。但 Mem0 可能根本不是为这种 benchmark 设计的——它面向的是生产场景下的实时记忆管理,不是学术检索评测。
2)Mastra 的 94.87% 和 MemPal 的 96.6% 在不同条件下测的
Mastra 用了 GPT-5-mini 做记忆提取,是完整的生产流水线。MemPal 的 96.6% 是在 benchmark 的小 haystack 上跑的纯检索。这两个数字的可比性很弱,但放在一张表里就产生了"我们比 Mastra 强"的暗示。
3)没有列出真正可比的纯检索 baseline
表中列了 BM25(70%)、Contriever(78%)、Stella(85%),都远低于 MemPal。但这些是学术论文里报告的数字,它们的实验条件(haystack 大小、评测协议)和 MemPal 自己跑的未必一致。作者没有在同一套代码里复现这些 baseline,所以对比的公平性不确定。
三、多个 benchmark 的"汇总效应"
文档列出了 4 个 benchmark 的结果:
| Benchmark | 亮眼数字 | 不那么亮眼的数字 |
|---|---|---|
| LongMemEval | 96.6% / 100% | — |
| ConvoMem | 92.9% | — |
| LoCoMo | 100%(with rerank) | 60.3%(raw top-10) |
| MemBench | 80.3% overall | 43.4%(noisy) |
读者看到的是 “4 个评测都有好成绩”,但实际上:
- LongMemEval 和 ConvoMem 对 raw 友好(前面分析过)
- LoCoMo 的 100% 是 top-50 拿的(作者自己说是结构性作弊,因为 top-k > session 总数)
- MemBench 的 overall 80.3% 掩盖了 noisy/conditional 类别的崩溃
多个 benchmark 同时出现,会产生"全面验证"的错觉,即使每个 benchmark 单独看都有明显的局限性。
四、"改进路径"的叙事魔法
96.6% → 97.8% → 98.4% → 98.8% → 99.4% → 100%
这个递进看起来像是系统性工程——每一步都有分析、有原因、有修复。但实际上:
- 96.6% → 99.4%:加了关键词加权、时间加权、偏好提取正则——这些是在测试集上观察错误案例后设计的修复
- 99.4% → 100%:作者自己说了,是针对最后 3 道题专门设计的 fix(quoted phrase boost、person name boost、nostalgia pattern)
这种叙事在论文里叫 ablation study(消融实验),读起来很 scientific。但本质上是:看哪道题错了 → 写规则修 → 重新跑分。在 held-out 450 题上,hybrid_v4 实际是 98.4%,而不是 100%。
五、"无 LLM"的框架效应
文档反复强调:
“No API key. No LLM. $0 cost per query.”
这创造了一个 David vs Goliath 叙事——一个免费、本地、零依赖的系统打败了烧钱的商业产品。这种故事天然有传播力,读者会下意识地 root for the underdog。
但这个框架隐藏了一个事实:正因为不用 LLM,它才只能做检索,做不了推理。 MemBench noisy 43.4%、LoCoMo temporal-inference 46%(baseline),这些需要推理能力的场景直接暴露了无 LLM 的上限。
六、作者的诚实 vs 文档的结构
公平地说,作者确实在文档里承认了所有这些问题:
- 第 456 行:“This is teaching to the test.”
- 第 498 行:“The honest LoCoMo score is the top-10 result: 60.3%”
- 第 461 行:“In a peer-reviewed paper this would be a significant methodological problem.”
- 第 477 行:主动做了 train/test split,held-out 成绩是 98.4%
这说明作者不是在故意欺骗。但文档的结构性效果是:亮眼的数字在前面反复出现,诚实的限定在后面一次性交代。大多数读者只看到前者。
总结:体验差异的根源
| 制造"很厉害"印象的因素 | 实际情况 |
|---|---|
| 96.6% / 100% 放在标题位置 | 96.6% 在宽松条件下取得;100% 是 teaching to the test |
| “Beats Mem0 by 2x” | 不同 benchmark、不同设计目标的不公平对比 |
| 4 个 benchmark 全有好成绩 | 每个 benchmark 单独看都有显著局限 |
| 96.6% → 100% 递进改进 | 在测试集上迭代修 bug,held-out 只有 98.4% |
| “无 LLM、$0” | 正因为无 LLM,推理类任务直接崩溃 |
| 多个 baseline 对比表 | 对比条件不一定一致 |
核心原因:原理上它做的事情很简单(存原文 + 向量搜索),但 BENCHMARKS.md 通过叙事结构、对比选择、和多 benchmark 汇总,把这个简单的事情包装成了一个系统性的工程成就。 作者是诚实的,但文档的阅读体验与技术实质之间确实存在落差。
1936

被折叠的 条评论
为什么被折叠?



