p-memory 0.2.3

Embedded memory, knowledge graph, and note storage for Rust and Python
Documentation
# 架构

本文件说明 p-memory 的架构:分层、模块、数据表关系与关键时序。

## 1. 系统分层

```mermaid
flowchart TD
    subgraph H["宿主"]
        A["P-ai(Rust)"]
        C["angel_memory(Python)"]
    end
    A -->|"crate 依赖"| K["p-memory 核心"]
    C --> P
    P -->|"protocol.rs:版本化 JSON"| K
    K --> API["KnowledgeBase"]
    API --> D["memories / graph / notes / embeddings / search"]
    D --> ST["SQLite store.sqlite3:权威"]
    D --> IX["Tantivy text-v2:可重建投影,承载切片正文唯一副本"]
```

- Rust 宿主直接依赖核心 crate;Python 宿主经 `p_memory` 绑定,绑定只做类型转换并把调用交给 `protocol.rs` 的 JSON 分发。
- 存储分层与权威关系见 [overview](overview.md)。

## 2. 核心模块

```mermaid
flowchart TD
    lib["lib.rs — KnowledgeBase 入口"]
    lib --> memory["memory.rs — 记忆与生命周期"]
    lib --> gstore["graph.rs — 实体 / 关系 / 事件"]
    lib --> notes["notes.rs — 笔记与切片"]
    lib --> embed["embeddings.rs — 向量空间"]
    lib --> search["search.rs — 关键词 / 向量 / RRF"]
    lib --> preset["preset.rs — 预设检索:库预先配好的搜索方法"]

    memory --> storage
    gstore --> storage
    notes --> storage
    embed --> storage
    search --> storage
    search --> index["index.rs — Tantivy 全文投影"]
    preset --> search
    preset --> storage
    gstore --> gsearch["graph_search.rs — 内存态图搜索(petgraph)"]

    storage["storage.rs — 事务 / 读写 / strings 映射 / 锁 / 备份"]
    storage --> schema["schema.rs + schema.sql — 建表与版本"]
    storage --> types["types.rs — 公共类型"]
    storage --> text["text.rs — 归一化 / 分词 / 摘要"]
    storage --> err["error.rs — 错误码"]

    legacy["legacy.rs — 离线导入"] --> storage
    protocol["protocol.rs — 版本化 JSON 分发"] --> lib
```

| 模块 | 职责 |
|---|---|
| `lib.rs` | 唯一入口 `KnowledgeBase`,聚合五个域 |
| `storage.rs` | 事务封装、记录读写、读连接池(空闲复用 / 池空新建)、向量分区缓存、`strings` 字典映射、写锁、备份恢复 |
| `types.rs` | `RecordInput`/`RecordHeader`/`ReadFilter`/`PageRequest`/`WriteReceipt`/`Evidence` |
| `text.rs` | 归一化、CJK 分词、内容摘要 |
| `error.rs` | 错误类型与错误码 |
| `schema.rs` / `schema.sql` | 建表语句、schema 版本、初始化 |
| `memory.rs` / `graph.rs` / `notes.rs` / `embeddings.rs` / `search.rs` | 五个领域 |
| `preset.rs` | 预设检索:按预设名组织记忆 / 图谱 / 笔记三路,各自独立排序、各自按字符数封顶 |
| `index.rs` | Tantivy 全文投影,可重放 |
| `graph_search.rs` | 内存态图搜索:按 `ReadFilter` 读一次快照建 petgraph 图,补虚拟边与 `sys:same_as` 缩点,用完即弃 |
| `legacy.rs` | 三来源离线导入 |
| `protocol.rs` | 给薄绑定用的版本化 JSON 边界,只解码与分发 |

## 3. 数据表关系

`strings` 是唯一的字典表:所有会重复出现的标记(tag、namespace、scope、entity_type、predicate、attr_key、source,以及 payload 内的 `memory_type`)都只把文本存这一份,别处只存整数 id。`kind` 是编译期可穷举的固定枚举,直接存整数、不建表。

```mermaid
erDiagram
    strings }o--o{ records : "namespace_id / scope_id"
    strings }o--o{ record_tags : "tag_id"
    records ||--o{ record_tags : "record_id"

    records ||--|| entities : "record_id"
    strings }o--o{ entities : "entity_type_id"
    entities ||--o{ entity_aliases : "entity_id"
    strings }o--o{ entity_aliases : "alias_id"
    entities ||--o{ entity_attributes : "entity_id"
    strings }o--o{ entity_attributes : "attr_key_id"

    records ||--|| relations : "record_id"
    entities ||--o{ relations : "subject_id / object_id"
    strings }o--o{ relations : "predicate_id"

    records ||--o{ event_participants : "event_id"
    entities ||--o{ event_participants : "entity_id"

    records ||--|| notes : "record_id"
    strings }o--o{ notes : "namespace_id / scope_id"
    notes ||--o{ chunks : "note_id"
    records ||--|| chunks : "record_id"

    records ||--o{ embeddings : "record_id"
    embedding_spaces ||--o{ embeddings : "space_id"

    strings {
        INTEGER id PK
        TEXT text
    }
    records {
        INTEGER id PK
        INTEGER namespace_id FK
        INTEGER kind
        INTEGER scope_id FK
        INTEGER revision
        TEXT payload_json
        TEXT fingerprint
    }
    record_tags {
        INTEGER record_id FK
        INTEGER tag_id FK
    }
    entities {
        INTEGER record_id PK
        TEXT name
        INTEGER entity_type_id FK
    }
    entity_aliases {
        INTEGER entity_id FK
        INTEGER alias_id FK
    }
    entity_attributes {
        INTEGER entity_id FK
        INTEGER attr_key_id FK
        TEXT attr_value
    }
    relations {
        INTEGER record_id PK
        INTEGER subject_id FK
        INTEGER predicate_id FK
        INTEGER object_id FK
    }
    event_participants {
        INTEGER event_id FK
        INTEGER entity_id FK
    }
    notes {
        INTEGER record_id PK
        INTEGER namespace_id FK
        INTEGER scope_id FK
        TEXT path
    }
    chunks {
        INTEGER record_id PK
        INTEGER note_id FK
        INTEGER ordinal
        INTEGER offset
        INTEGER limit
        TEXT fingerprint
    }
    embedding_spaces {
        TEXT id PK
        TEXT model
        INTEGER dimension
        INTEGER text_version
    }
    embeddings {
        TEXT space_id FK
        INTEGER record_id FK
        TEXT fingerprint
        BLOB vector
    }
```

关键点:

- `records` 是所有领域的宽表,主键 `id` 自增整数,与任何外部 ID 无关;`kind` 区分领域。领域正文的权威:记忆在 `payload_json` 的 `judgment`;笔记切片的正文唯一副本在全文索引的 stored 列(写入时从源文件读一次、切好带过去),`payload_json` 只留切片粒度,路径在 `notes` 表。记忆的 `memory_type` 也以 `memory_type_id` 存在 payload 内,指向 `strings`,读取时还原文本。
- 可检索正文与向量输入都是**派生文本**,不落 SQLite 列:写入时按 `kind` 从 payload(笔记则读文件)现算,可检索正文交给 Tantivy 的 stored 字段。
- 各领域投影表(`entities`/`relations`/`notes`/`chunks` 等)的 `record_id` 直接复用 `records.id`,靠外键与级联删除维持一致性。
- 笔记的路径与文件名都在 `notes` 表存一次;领域必须登记过根目录(`namespace_roots`)才允许写入,路径存的是减掉根目录的相对路径,文件名是写入时用 `file_stem` 取下的 `name` 列。路径拆成目录段与文件名后分别落在索引的 `path` 列与 `name` 列上,只挂在这一篇第一片;目录段不参与常规匹配,只在「书名块不够」的兜底查询里被查。切片还带一条 `note` 列(所属笔记 id),用来按笔记精确统计「这一篇有多少片段命中」,检索时同一篇只保留排名最高的一片。路径段仍写在每一条切片记录上供筛选。
- `chunks.fingerprint` 保留「内容未变则复用向量」的语义:`ordinal` 与内容都没变的切片复用原 `record_id`,向量继续有效。记录指纹按「正文 + 标签」算,标签换了(例如刚登记根目录拆出路径段)指纹就换,旧向量作废。
- `embedding_spaces` 的 `id` 是文本(模型标识),`embeddings` 是「空间 × 记录」的复合主键。
- `index_updates`、`import_runs`、`meta` 是辅助表:索引重放日志、导入批次指纹、修订号计数器。

## 4. 一次写入的时序

调用方报字符串,核心在存储层内部换算成 id。

```mermaid
sequenceDiagram
    participant H as 宿主
    participant K as KnowledgeBase
    participant S as storage(SQLite 事务)
    participant I as Tantivy

    H->>K: upsert(input)(namespace / scope / tag 均为字符串;笔记按 path 读文件并切好正文)
    K->>S: mutate() 开启 IMMEDIATE 事务
    S->>S: 字符串 → strings id(不存在则插入新行)
    S->>S: 写 records 与投影表,revision += 1,登记 index_updates
    S->>S: 该领域各档的就绪标记就地作废
    S-->>K: 提交事务
    K->>I: 就地写入索引文档(正文一路带过来,未提交、对搜索不可见)
    K-->>H: WriteReceipt{ revision }
    H->>K: update_index()(批量导入后调用一次)
    K->>I: 一趟提交并记账(只提交,不回读文件)
    H->>K: embeddings().sync(space, batch)(批次结束后触发一次)
    K->>K: 先追平索引,再调模型补齐缺口,最后逐档核对缺口标成就绪
```

- 数据提交与索引更新分开:写入只登记待办(`index_updates`)并在同一个调用里把文档写进索引 writer,不 commit;索引由使用方在合适时机调用 `update_index()` 一趟提交、只提交一次。读取若发现待办会尽力自愈(抢不到写锁就跳过,绝不排队),所以写入后照样能查到,只是索引可能滞后到那一刻。
- **写入一行向量都不产生、一次模型都不调**。向量化是独立的一步:批次结束后由调用方调 `sync` 补齐,库内线程兜底,且线程是纯事件触发(开库、注册或切换模型、改档位、写入提交后各叫一次),不轮询、不设定时。库内线程只写 `embeddings` 表,不写业务记录、不改索引。
- 正文只读一次、只切一次:写入时就地切好,随文档一路带到索引;索引阶段不再回读源文件。唯一需要回源的是重建(格式升级、索引损坏、上次写入未收尾)与向量化取切片正文——后者的正文只存在索引里,所以补齐前先把索引追平。