MD 状态:🌱 更新:2026/6/9

MemPalace 架构分析

[!info] 知识库定位 这是一篇 项目案例 / 架构分析,重点记录”这个项目如何落地某些概念和工具”。 通用概念链接到 related_concepts;工具评估和使用方法链接到 related_tools

MemPalace 是一个 本地优先、逐字原文存储的 AI 记忆系统,采用双插件架构(存储后端 + 内容源适配器),通过”宫殿隐喻”(Wing → Room → Drawer)组织对话和项目历史,核心搜索路径不需要 LLM API。

核心问题与约束

MemPalace 存在要解决的核心问题:LLM 的上下文窗口有上限,跨会话记忆无法保留

它的设计约束:

  • 隐私优先:数据不出本地,核心路径零 API 调用
  • 保真度:不对内容做摘要或提取,保留逐字原文——摘要即信息丢失
  • 可验证性:所有 benchmark 可在仓库内复现,不接受不可复现的指标
  • 多后端适配:不同用户有不同的存储偏好(本地 ChromaDB / 远程 Qdrant / 已有 Postgres)

架构全景

graph TD
    subgraph "入口层 Entry"
        CLI["CLI<br/>cli.py"]
        MCP["MCP Server<br/>mcp_server.py<br/>(29 tools, stdio JSON-RPC)"]
        PYAPI["Python API"]
    end

    subgraph "核心域 Core Domain"
        MINER["Miner<br/>miner.py / convo_miner.py"]
        PALACE["Palace Manager<br/>palace.py"]
        SEARCH["Hybrid Searcher<br/>searcher.py"]
        KG["Knowledge Graph<br/>knowledge_graph.py<br/>(SQLite 时序 ER 图)"]
        EMBED["Embedding<br/>embedding.py"]
    end

    subgraph "双集合模型 Dual Collection"
        DRAWERS["mempalace_drawers<br/>(逐字原文 chunks)"]
        CLOSETS["mempalace_closets<br/>(主题/实体指针)"]
    end

    subgraph "插件层 Plugin Layer"
        subgraph "存储后端 RFC 001"
            B_BASE["BaseBackend<br/>BaseCollection<br/>(backends/base.py)"]
            CHROMA["ChromaDB"]
            SQLITE["SQLite Exact"]
            QDRANT["Qdrant"]
            PGVEC["pgvector"]
        end
        subgraph "内容源适配器 RFC 002"
            S_BASE["BaseSourceAdapter<br/>(sources/base.py)"]
            FS["FileSystem"]
            CONVOS["Conversations"]
        end
    end

    CLI --> MINER
    CLI --> SEARCH
    MCP --> SEARCH
    MCP --> PALACE
    PYAPI --> PALACE

    MINER --> PALACE
    PALACE --> DRAWERS
    PALACE --> CLOSETS
    MINER --> EMBED
    SEARCH --> DRAWERS
    SEARCH --> CLOSETS

    DRAWERS --> B_BASE
    CLOSETS --> B_BASE
    B_BASE --> CHROMA
    B_BASE --> SQLITE
    B_BASE --> QDRANT
    B_BASE --> PGVEC

    MINER --> S_BASE
    S_BASE --> FS
    S_BASE --> CONVOS

    PALACE --> KG

架构师注释:最关键的设计决策是 双集合模型(drawers + closets)和 双插件架构(backends + sources)。drawers 存原文、closets 存指针,搜索时 closets 只是 ranking signal 而非 gate——这是搜索正确性的架构保障。双插件通过 ABC(backends/base.py, sources/base.py)定义契约,通过 registry 实现运行时发现。

架构风格:分层 + 双插件

MemPalace 采用 分层架构 + 双插件系统

职责文件
入口层CLI / MCP / Python API 三个入口cli.py, mcp_server.py
域逻辑层Palace 管理、Mining、Search、知识图谱palace.py, searcher.py, miner.py
数据模型层双集合(drawers + closets)ChromaDB collections
插件层存储后端(RFC 001)+ 内容源适配器(RFC 002)backends/, sources/

带来了什么

  • 存储后端可替换(ChromaDB → Qdrant → pgvector)而不改核心逻辑
  • 内容源可扩展(文件系统 → Git → Slack → Cursor)而不改 Mining 管线
  • 层间依赖单向(入口 → 域逻辑 → 数据模型 → 插件),无循环依赖

牺牲了什么

  • 插件契约(ABC)的变更会波及所有实现——接口稳定性是关键风险
  • 双插件增加了测试矩阵(N backends × M sources × K 测试用例)

核心模块解析

模块单一职责与其他模块的耦合点
palace.pyPalace 生命周期管理(集合访问、Closet 构建、文件锁、幂等检查)调用 backends/ 获取集合;被 miner.py / mcp_server.py / searcher.py 调用
searcher.py混合搜索(向量 + BM25 + Closet 提升 + 邻居扩展)drawers + closets 集合;被 cli.py / mcp_server.py 调用
miner.py / convo_miner.py内容挖掘(文件 → chunks → drawers + closets)调用 palace.py 写入;调用 embedding.py 向量化;调用 normalize.py 清洗
backends/base.py存储后端契约定义(ABC + typed results)被所有后端实现继承;被 palace.py / searcher.py 通过接口消费
sources/base.py内容源适配器契约(RFC 002)miner.py 调用;定义 SourceRef / DrawerRecord 数据类型
knowledge_graph.py时序实体-关系图(SQLite)mcp_server.py 暴露为 MCP 工具;独立于向量存储
embedding.py嵌入模型管理(本地/远程)miner.py / searcher.py 调用
mcp_server.pyMCP 协议层(29 tools, stdio JSON-RPC)调用 palace.py / searcher.py / knowledge_graph.py
hallways.py跨 Wing 导航读取 drawers 元数据;被 MCP server 暴露
entity_detector.py实体检测(i18n 感知)palace.pybuild_closet_lines() 调用

palace.py — 核心域模型

palace.py 是整个系统的引力中心。它管理两个 ChromaDB 集合的完整生命周期:

# 双集合访问(palace.py)
_DEFAULT_BACKEND = ChromaBackend()

def get_collection(palace_path, collection_name="mempalace_drawers", create=True):
    """获取 drawers 集合(逐字原文 chunks)"""
    return _DEFAULT_BACKEND.get_collection(palace_path, ...)

def get_closets_collection(palace_path, create=True):
    """获取 closets 集合(主题/实体指针)"""
    return get_collection(palace_path, collection_name="mempalace_closets")

关键设计:

  • NORMALIZE_VERSION = 2:schema 版本控制,旧版本 drawers 自动触发重建
  • mine_lock():跨平台文件锁(Windows 用 msvcrt,Unix 用 fcntl),防止并发挖掘产生重复 drawers
  • file_already_mined():幂等检查,结合 normalize_version + mtime 判断是否需要重新挖掘
  • build_closet_lines():从 drawer 内容提取实体(i18n 感知)、话题、引言,构建紧凑指针

searcher.py — 四阶段混合搜索

# 搜索架构(searcher.py 核心逻辑)
# 阶段 1:向量搜索(3x over-fetch)
drawer_results = drawers_col.query(query_texts=[query], n_results=n_results * 3)

# 阶段 2:Closet 提升排名(rank-based boost)
CLOSET_RANK_BOOSTS = [0.40, 0.25, 0.15, 0.08, 0.04]
effective_dist = dist - boost  # closet 匹配降低有效距离

# 阶段 3:Drawer-grep 增强(关键词最佳 chunk + 邻居)
# 对 closet 提升的 hits,在同一源文件中用关键词找最佳 chunk

# 阶段 4:BM25 重排
final_score = 0.6 * vector_sim + 0.4 * bm25_norm

核心设计哲学(来自 searcher.py 文档注释):

“The drawer query is the floor — always runs — and closet hits add a rank-based boost when they agree. Closets are a ranking signal, never a gate, so weak closets can only help, never hide drawers the direct path would have found.”

这意味着即使 closet 索引质量差(如叙事性内容提取到的话题信号弱),搜索结果也不会比直接向量搜索差——最坏情况下 closet boost = 0,退化为纯向量搜索。

数据流分析

flowchart LR
    subgraph "数据入口"
        SRC["源文件<br/>.py .md .jsonl"]
        CONVO["对话记录<br/>Claude Code sessions"]
    end

    subgraph "Mining 管线"
        SCAN["文件扫描<br/>project_scanner.py"]
        READ["内容读取<br/>format_miner.py"]
        NORM["文本清洗<br/>normalize.py"]
        CHUNK["分块<br/>split_mega_files.py"]
        EMB["向量化<br/>embedding.py"]
    end

    subgraph "存储"
        DRAWER["Drawers<br/>逐字原文 chunks<br/>+ 元数据"]
        CLOSET["Closets<br/>topic|entities|→drawer_ids<br/>≤1500 chars"]
        KG["Knowledge Graph<br/>时序实体关系<br/>SQLite"]
    end

    subgraph "搜索"
        VEC["向量搜索<br/>ChromaDB HNSW"]
        CLBST["Closet 提升排名<br/>rank-based boost"]
        GRP["Drawer-grep<br/>关键词重定位"]
        BM25["BM25 重排<br/>0.6×vec + 0.4×bm25"]
    end

    SRC --> SCAN --> READ --> NORM --> CHUNK --> EMB
    CONVO --> READ
    EMB --> DRAWER
    CHUNK --> DRAWER
    NORM --> CLOSET
    CHUNK --> KG

    DRAWER --> VEC --> CLBST --> GRP --> BM25
    CLOSET --> CLBST

架构师注释:数据流的关键特征是 写入时构建索引(closets 在 mine 时同步生成),搜索时只做读取 + 重排。这是一种 CQRS-lite 模式:写入路径(mine)负责完整的数据准备(原文 + 向量 + 指针 + 知识图谱),读取路径(search)只做检索和排序。

关键架构决策

决策 1:逐字原文存储 vs 摘要提取

  • 背景:AI 记忆系统需要把长对话压缩到有限的存储中。业界主流(mem0 / Zep)选择用 LLM 提取摘要
  • 决策:MemPalace 选择逐字原文存储,不做任何摘要或提取
  • 原因:摘要即信息丢失。LLM 提取的”重点”可能恰好丢掉你未来需要检索的细节。原文保证检索结果就是当时真实说过的话
  • 代价:存储占用远大于摘要方案;长对话会生成大量 chunks,搜索候选集更大
  • 风险:当数据量增长到数万个源文件时,纯向量搜索的召回质量是否仍然足够——目前 benchmark(LongMemEval 500 问)规模有限,超大规模场景待验证

决策 2:双集合模型(Drawers + Closets)

  • 背景:纯向量搜索在叙事性内容上表现不佳,需要一种辅助索引提升召回。但辅助索引质量参差不齐
  • 决策:将存储拆为两个集合:mempalace_drawers(原文 chunks)和 mempalace_closets(紧凑指针 topic|entities|→drawer_ids)。搜索时 closets 只做 ranking signal,永远不做 gate
  • 原因:closet 提取基于正则(build_closet_lines()),在叙事性内容上信号弱。如果 closets 做 gate(先查 closet 再查 drawer),弱 closets 会直接过滤掉本应命中的 drawers。signal-only 设计确保搜索质量的下限 = 纯向量搜索
  • 代价:双集合意味着双倍的向量存储 + 索引维护开销。每次 mine 需要同步构建 drawers 和 closets
  • 风险:closet 的 CLOSET_RANK_BOOSTS = [0.40, 0.25, ...] 是硬编码的经验值,未提供配置接口。不同数据分布下可能不是最优权重

决策 3:可插拔存储后端(RFC 001)

  • 背景:不同用户有不同的存储偏好和约束(本地文件 / 已有 Postgres / 云端 Qdrant)
  • 决策:通过 BaseBackend + BaseCollection ABC 定义存储契约,支持 ChromaDB / SQLite Exact / Qdrant / pgvector 四种后端。每个后端通过 name ClassVar 注册
  • 原因:避免被单一向量数据库绑定(尤其是 ChromaDB 曾出现 HNSW 损坏问题 #976)。多后端也确保接口不被单一供应商的 API 形状绑架
  • 代价:后端一致性测试矩阵膨胀(_backend_conformance.py 定义了合规测试套件)。不同后端的性能特征差异大,需要用户自己判断适用场景
  • 风险BaseCollectionupdate() 默认实现是非原子的(get + merge + upsert),只有声明 supports_update 的后端才需要覆盖为原子实现

决策 4:核心路径零 LLM 依赖

  • 背景:大多数 AI 记忆系统(mem0 / Zep)依赖 LLM 做提取和摘要,每次写入都产生 API 成本
  • 决策:MemPalace 核心路径(mine + search)完全不调用 LLM。实体提取用正则 + i18n 模式,搜索用向量 + BM25
  • 原因:(1) 隐私——数据不出本地;(2) 成本——无 API 费用;(3) 确定性——同样的输入永远产生同样的输出,benchmark 可复现
  • 代价:实体和话题提取质量上限受限于正则规则。LLM rerank(closet_llm.py)是可选增强,非核心路径
  • 风险:当对话内容涉及领域术语、代码变量名、缩写等非自然语言时,正则提取可能遗漏关键实体

请求链路

核心搜索链路

sequenceDiagram
    participant User as 用户/AI
    participant Entry as CLI/MCP
    participant Search as searcher.py
    participant Drawers as drawers 集合
    participant Closets as closets 集合
    participant BM25 as BM25 Re-rank

    User->>Entry: search("为什么换 GraphQL")
    Entry->>Search: search_memories(query)

    Note over Search: 阶段1: 向量搜索 (3x over-fetch)
    Search->>Drawers: query(query_texts, n_results×3)
    Drawers-->>Search: 候选 drawers (text, meta, distance)

    Note over Search: 阶段2: Closet 提升排名
    Search->>Closets: query(query_texts, n_results×2)
    Closets-->>Search: closet hits (rank, distance)
    Search->>Search: 计算 rank-based boost<br/>[0.40, 0.25, 0.15, 0.08, 0.04]

    Note over Search: 阶段3: Drawer-grep 增强
    Search->>Drawers: get(source_file=matched)
    Drawers-->>Search: 同文件所有 chunks
    Search->>Search: 关键词找最佳 chunk + 邻居

    Note over Search: 阶段4: BM25 重排
    Search->>BM25: _hybrid_rank(hits, query)
    BM25->>BM25: 0.6×vector + 0.4×bm25

    Search-->>Entry: ranked results
    Entry-->>User: 逐字原文 + 元数据

架构师注释:搜索链路的关键设计是 closet 永不做 gate。如果 closets 查询失败(如尚无 closet 数据),整个搜索优雅降级为纯向量搜索——不会报错,不会返回空结果。这种 “signal, not gate” 的设计理念值得在任何辅助索引场景中借鉴。

Mining 链路

sequenceDiagram
    participant User as 用户
    participant CLI as CLI
    participant Miner as miner.py
    participant Palace as palace.py
    participant Embed as embedding.py
    participant Backend as ChromaBackend

    User->>CLI: mempalace mine ~/project
    CLI->>Miner: mine(path)

    loop 每个文件
        Miner->>Palace: mine_lock(source_file)
        Miner->>Palace: file_already_mined(col, source_file)
        alt 已挖掘且版本一致
            Miner->>Miner: 跳过
        else 未挖掘或版本过期
            Miner->>Miner: 读取 + 清洗 + 分块
            Miner->>Embed: 向量化 chunks
            Miner->>Palace: 删除旧 drawers + closets
            Miner->>Backend: upsert(drawers + embeddings)
            Miner->>Palace: build_closet_lines()
            Palace->>Backend: upsert(closets)
        end
        Miner->>Palace: 释放 lock
    end

架构质量评估

质量属性设计手段(引用具体文件/行)评估潜在风险
可用性search_memories() 中 closet 查询失败时 except: pass,退化为纯向量搜索(searcher.py★★★★☆ChromaDB HNSW 损坏时整个搜索不可用(#976 已修复但底层问题仍在)
可扩展性双 ABC 插件(backends/base.py, sources/base.py)+ Registry 模式(backends/registry.py★★★★☆后端接口变更会波及所有实现;目前后端一致性测试覆盖但无版本兼容保证
可维护性模块职责清晰(~30 个独立文件),测试覆盖率高(100+ 测试文件),pyproject.toml 要求 fail_under = 85★★★★☆_DictCompatMixin 是过渡代码(从 dict 迁移到 typed results),增加了维护负担
性能向量搜索 3x over-fetch + BM25 重排;本地嵌入模型(~300MB);跨平台文件锁★★★☆☆3x over-fetch 在大规模数据下可能导致延迟显著增加;BM25 在候选集上计算非预建索引
安全性containment-zones 文件锁;closet 指针不存储原文内容;i18n-aware 实体检测★★★☆☆mine_lock() 基于文件锁而非数据库级锁,多进程场景可能竞争

架构风险与改进建议

[!danger] 风险点

  1. HNSW 索引健壮性(#976):ChromaDB 的 HNSW 索引在异常退出时可损坏。虽然已修复但属于底层依赖问题,非 MemPalace 可控。建议:实现 sqlite_exact 后端的自动故障转移
  2. Closet 提取质量上限build_closet_lines() 依赖正则提取实体和话题,对非英语/非自然语言内容效果有限(_ENTITY_STOPLIST 只有英语,话题正则只匹配英语动词模式)。虽然 i18n 扩展了实体检测,但话题提取仍为英语中心
  3. 硬编码参数CLOSET_RANK_BOOSTSvector_weight=0.6bm25_weight=0.4CLOSET_CHAR_LIMIT=1500 等关键参数硬编码在代码中,不可通过配置文件调整
  4. 全局单例后端_DEFAULT_BACKEND = ChromaBackend() 是模块级全局变量,限制了同一进程内使用多后端的能力

[!tip] 改进建议

  1. 将搜索参数外部化到 mempalace.yamlCLOSET_RANK_BOOSTS、权重、over-fetch 倍数等应可配置,让用户根据数据分布调优
  2. Closet 提取策略插件化:允许用户注入自定义的 closet 提取逻辑(如 LLM-based extraction),而不是硬编码正则
  3. 后端健康检查 + 自动降级BaseBackend.health() 已定义但未被搜索路径使用。建议在搜索前检查后端健康度,不健康时自动切到 sqlite_exact
  4. 消除 _DEFAULT_BACKEND 全局变量:通过依赖注入或 Context 传递后端实例,提升可测试性和多后端场景支持

可复用架构经验

值得借鉴的模式:

  • “Signal, Not Gate” 辅助索引模式(适用条件:辅助索引质量不稳定时)— searcher.py 中 closets 只做 ranking boost 不做过滤。任何”可能有帮助但不能保证质量”的索引都应该遵循这个原则:它只能提升结果,不能过滤结果。这个模式可以直接迁移到任何 RAG 系统的元数据索引设计中

  • 双插件架构 + RFC 驱动(适用条件:系统需要在两个正交维度上支持第三方扩展时)— backends/base.py(RFC 001)+ sources/base.py(RFC 002)形成了读写两侧的对称插件体系。每个插件通过 ABC 定义契约 + ClassVar 声明身份 + Registry 实现运行时发现。PalaceRef / SourceRef 作为值对象隔离了具体后端细节

  • 版本门控的幂等 Mining(适用条件:需要重复执行但不产生副作用的 ETL 管线)— file_already_mined() 通过 NORMALIZE_VERSION + source_mtime 双重检查实现幂等。当 normalization 管线升级时,旧版本 drawers 自动触发重建,用户无需手动清理

  • 跨平台文件锁(适用条件:需要多进程互斥但不想引入重量级锁服务时)— mine_lock() 使用 os.name == "nt" 分支选择 msvcrt / fcntl,配合 hashlib.sha256 哈希路径生成锁文件名,简洁有效

  • 类型化结果 + 迁移垫片(适用条件:API 从 dict 迁移到 typed dataclass 时)— QueryResult / GetResult 继承 _DictCompatMixin,同时支持 result.idsresult["ids"] 两种访问方式,平滑过渡

值得警惕的反模式:

  • 正则硬编码的话题提取(出现场景:需要从非结构化文本中提取语义信息但不想引入 LLM 时)— build_closet_lines() 中的话题正则只匹配英语动词模式(built|fixed|wrote|added|...),对中文、日文等语言完全失效。虽然 i18n 扩展了实体检测,但话题提取仍是英语中心。这导致非英语内容的 closet 质量显著低于英语内容

  • 模块级全局状态(出现场景:简单项目快速启动时)— _DEFAULT_BACKEND = ChromaBackend() 作为模块级全局变量,虽然方便但限制了多后端场景和可测试性。随项目规模增长,这种全局状态会成为隐藏的耦合点


关联概念

架构风格插件架构 · 分层架构 · CQRS 技术选型ChromaDB · 向量数据库 · 嵌入模型 · MCP 相关项目Goose · Hermes Agent 通用概念RAG · 语义搜索 · 知识图谱 · BM25 工具笔记MemPalace 使用笔记 · mem0