Skip to content

Data Model —— 006 Knowledge Base(重新设计)

English: data-model.md

知识基底 KB 面的实体、端口、统一的 SQLite schema(与 memory kind,spec 007,共享)以及磁盘布局。架构不变量在 .specify/memory/constitution.md;本文件只描述模型。

原则:磁盘上的 Markdown 文件是唯一真相源。每一行 SQLite(documentschunksdocuments_ftsvec_chunks)都是可由文件经 reindex 例程派生重建的。不存在双真相源。

Domain 实体

基底 domain(backend/coffer/domain/knowledge/)与 memory kind 共享;KB 专属的配置与 doc-id 辅助函数在 backend/coffer/domain/knowledge_base/

KnowledgeBaseConfig (domain/knowledge_base/config.py)

Pydantic v2 BaseModel。当 kind == "knowledge_base" 时存在 Resource.config 里。所有字段创建后可变(改 chunk 参数或 embedding 模型会触发 reindex/重新 embedding —— 没有不可变锁)。

检索 mode 是引擎内部概念(ADR-034):enabled_modes / default_mode 仍是内部配置 —— 它们配置引擎解析出的策略,但在任何外部界面(REST / MCP / CLI / UI)暴露用于逐查询选择。检索是「一次查询 → 一个答案」。

FieldTypeNotes
enabled_modeslist[RetrievalMode]{"grep","keyword","vector","hybrid"} 的子集。默认 ["keyword","grep"]vector 可选,需要 embedding;启用 vector 会自动加入 hybrid(对 keyword+vector 做 RRF 融合,ADR-012)。
default_modeRetrievalModesearch 省略 mode 时使用的模式。默认 "keyword";启用 vector 时(除非显式指定)自动变为 "hybrid"
chunk_sizeint默认 512;范围 64–2048
chunk_overlapint默认 64;范围 0–chunk_size/2
max_document_bytesint默认 25 * 1024 * 1024;范围 1024–104857600
embeddingEmbeddingConfig | None仅当启用 vector 时必需。None ⇒ 仅 keyword/grep。
auto_update_sourcesbool默认 false。为 true 时,check_sources 自动刷新外部原件已变化的被跟踪文档(跳过手动编辑过的)。不是触发 reindex 的字段——切换它绝不会重新 chunk / 重新 embedding。

EmbeddingConfig (domain/knowledge/embedder.py)

DevPilot 风格的 OpenAI 兼容 provider 抽象(一个 AsyncOpenAI 客户端,可换 base_url),外加一个 in-process local 选项。

FieldTypeNotes
providerstr"openai""openrouter""voyage""jina""gemini""azure""dashscope""ollama""lmstudio",或 "local"(fastembed)。
modelstrembedding 模型 id,如 text-embedding-3-smallbge-m3
base_urlstr | NoneOpenAI 兼容端点;None 用 provider 默认值;local 时忽略。
credential_refstr | None加密存储引用(绝不明文);local / 免 key 端点用 None。可选回退到 LLM 凭据引用。
dimensionsint向量宽度;决定 vec_chunks 的列宽。宽度变化时重新 embedding 会重建 vec 表。

Document (domain/knowledge/document.py)

frozen dataclass;一个 Markdown 文件一条,kind 区分。KB 与 memory 共享该实体;KB 专属数据放在 metadata 里。

FieldTypeNotes
idstr首次 ingest 时生成的稳定 ULID(KB + memory)—— 即 doc id 与 <doc-id>.md 文件名。与内容解耦;源哈希仅作为出处保留。
kindstrKB 行是 "knowledge_base"
resource_namestrKB 资源名。
pathstrMarkdown 文件的相对路径,docs/<doc-id>.md
titlestr来自 frontmatter / 第一个标题 / 文件名。
descriptionstr | None可选摘要。
content_sha256strMarkdown 正文的哈希 —— reindex 的 no-op 闸门。始终是真实正文哈希(与 embed 重试状态解耦,KB8)。
embed_pendingbool索引派生的重试标志(默认 False):embed 降级(provider 不可用)时为 True,此时仅做 keyword 索引,下一次 reindex 只重试 embed。非文件真相 —— 永不写入 frontmatter(migration 0035)。
source_modestr"converted""edited"
metadatadict按面区分的 JSON;KB 的 key 见下。
created_at / updated_atdatetimeUTC。

KB 的 metadata key:original_filename(重新上传的匹配键)、original_formatsource_sha256(出处)、converted_atconversion_engine,以及可选的 source_path(外部原件的绝对路径,仅由基于路径的 ingest——CLI / 桌面选择器——写入,供 check_sources 之后检测其磁盘漂移;机器本地,web 字节上传与 agent add_document 绝不写入)。

PassageGrepHitSearchResult (domain/knowledge/retrieval.py)

frozen 值对象(不持久化):

  • Passagedocument_idtitletextscore: floatposition: int
  • GrepHitpathline_number: intline
  • GrepResulthits: Sequence[GrepHit]truncated: bool —— 当存在超出 max_matches 的匹配,服务端超时截断了扫描时,truncated 为 true(grep 超时会返回零命中且 truncated=true,同时 rg 进程被 kill)。
  • SearchResultmode: RetrievalModepassages: Sequence[Passage]fallback: RetrievalMode | None(当请求的 vectorhybrid 搜索降级为 keyword 时设置)。
  • RetrievalModeLiteral["grep","keyword","vector","hybrid"] —— hybridkeyword+vector 结果列表做 reciprocal rank fusion(K = 60,按 (document_id, position) 去重);融合是 KnowledgeRetrieval 门面里对索引已返回的两个列表所做的纯 Python 处理(不引入任何引擎)。与 memory 面(spec 007)共享。
  • StoreRefkindresource_nameproject_iddocs_dir —— 为共享检索门面标识一个逻辑 store(一个 KB 或一个 memory scope)。

端口 (domain/knowledge/)

typing.Protocol 表达 Coffer 需要什么,让具体引擎留在 infrastructure/

python
class MarkdownConverter(Protocol):
    def can_handle(self, fmt: str) -> bool: ...
    async def convert(self, data: bytes, fmt: str) -> tuple[str, dict]: ...  # (markdown, metadata)

class Embedder(Protocol):
    async def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
    @property
    def dimensions(self) -> int: ...

class KnowledgeIndex(Protocol):
    async def upsert_chunks(self, document_id: str, chunks: Sequence[str],
                            vectors: Sequence[Sequence[float]] | None) -> int: ...
    async def delete_chunks(self, document_id: str) -> None: ...
    async def keyword_search(self, resource_name: str, query: str, top_k: int) -> Sequence[Passage]: ...
    async def vector_search(self, resource_name: str, vector: Sequence[float], top_k: int) -> Sequence[Passage]: ...

grep 是独立的 infrastructure/knowledge/grep.py ripgrep 包装器(无索引),受 max-matches + 超时约束。

Domain 错误(加进 domain/errors.py

  • KBNotFound —— code "KB_NOT_FOUND"
  • DocumentNotFound —— code "DOCUMENT_NOT_FOUND"
  • IngestRejected —— code "INGEST_REJECTED";reason ∈ {"empty","too_large","unsupported_type","duplicate"}
  • EngineUnavailable —— code "ENGINE_UNAVAILABLE";当请求操作所需的转换器库 / sqlite-vec / embedding provider 不可用时抛出。
  • ReconversionBlocked —— code "RECONVERSION_BLOCKED";对 source_mode == "edited" 的文档执行重转换时抛出。
  • SearchModeInvalid —— 已移除(ADR-034)。外部搜索界面不再接受 mode,因此 SEARCH_MODE_INVALID(HTTP 400)不再适用;引擎在内部解析策略,grep 保留自己的端点。

SQLite schema(统一基底)

基底迁移(0006)删除旧的 kb_documents / memory_records 表并创建下面的统一 schema(没有数据迁移 —— 分支未发布)。0025 为共管锁加上的 locked 列,在撤回逐文档锁时由后续一个追加迁移(0027)再次删除。迁移 0035 加上 embed_pending 列(把 embed 重试状态与 content_sha256 解耦,KB8),并从已废弃的空字符串哨兵回填(embed_pending = 1 WHERE content_sha256 = '')。

sql
-- One row per Markdown file, shared by KB and memory, discriminated by `kind`.
CREATE TABLE documents (
    id              TEXT      NOT NULL,             -- ULID (KB + memory), minted at first ingest
    kind            TEXT      NOT NULL,             -- 'knowledge_base' | 'memory'
    resource_name   TEXT      NOT NULL,             -- KB name (or memory scope store name)
    project_id      TEXT      NOT NULL,             -- WORKSPACE_GLOBAL sentinel (KB/global) | project ULID
    path            TEXT      NOT NULL,             -- relative path of the markdown file
    title           TEXT      NOT NULL,
    description     TEXT,
    content_sha256  TEXT      NOT NULL,             -- hash of the markdown body (reindex no-op gate); always the real hash
    embed_pending   BOOLEAN   NOT NULL DEFAULT 0,   -- embed degraded (provider down) → retry just the embed next reindex (migration 0035)
    source_mode     TEXT      NOT NULL,             -- 'converted' | 'edited' (KB) | 'native' (memory)
    metadata        TEXT      NOT NULL DEFAULT '{}',-- JSON, per-face
    created_at      TIMESTAMP NOT NULL,
    updated_at      TIMESTAMP NOT NULL,
    PRIMARY KEY (kind, resource_name, id)
);
CREATE INDEX idx_documents_kind_res_time ON documents(kind, resource_name, updated_at DESC);
CREATE INDEX idx_documents_project ON documents(project_id);
-- KB dedup is on metadata->>'source_sha256'; enforced in the repo, not a SQL unique index,
-- because memory rows have no source file.

CREATE TABLE chunks (
    id              TEXT      NOT NULL PRIMARY KEY, -- '<store-scope>:<doc-id>:<position>'
    -- store-scope = 12-hex digest of (kind, resource_name): retained as defense-in-depth
    -- (doc ids are now globally-unique ULIDs, but this keeps chunk ids store-scoped)
    document_id     TEXT      NOT NULL,
    kind            TEXT      NOT NULL,
    resource_name   TEXT      NOT NULL,
    position        INTEGER   NOT NULL              -- 0-based ordinal within the document
    -- chunk TEXT is NOT stored in a base table; it lives in documents_fts + on disk
);
CREATE INDEX idx_chunks_document ON chunks(document_id);

-- FTS5 index over chunk text; bm25() ranks keyword search. The text lives once
-- inside the FTS index (not duplicated into a base SQLite table). chunk_id maps
-- a hit back to its chunks row; resource_name scopes the search. (A `content=''`
-- contentless table cannot return its own column values, so passages would need
-- a file read on every hit — the text is kept in the FTS index instead.)
CREATE VIRTUAL TABLE documents_fts USING fts5(
    text,
    resource_name UNINDEXED,
    chunk_id UNINDEXED,
    tokenize='trigram'  -- CJK-capable; unicode61 did not segment CJK (migration 0033)
);

-- sqlite-vec virtual table; one row per chunk with a vector. Created LAZILY by
-- vec_index.py at the configured width (per store), not in the migration.
CREATE VIRTUAL TABLE vec_chunks USING vec0(
    chunk_id TEXT PRIMARY KEY,                      -- bare '<doc-id>:<position>' (the table itself is per-store)
    embedding FLOAT[768]                            -- width set per KB at create/re-embed time
);

为什么用 (kind, resource_name, id) 复合主键:doc id 现在是全局唯一的 ULID(裸 id 本就唯一),但复合键让统一表在跨面/跨资源时无歧义,并让 on_delete 能把级联限定在单个资源内。同一源文件上传到两个 KB 是两个独立文档、两个 ULID(不跨 store 去重 —— ADR-028)。

为什么要 project_id:memory 面按 project 给 store 定键(global 用 sentinel ULID,其余用 project ULID)。KB 行携带 global sentinel —— 统一表需要这一列,让 memory 的 scope 查询有索引可用(idx_documents_project)。

级联在应用层(kind 的 on_delete 钩子),不是外键:钩子删除该资源的 documents/chunks/documents_fts/vec_chunks 行,然后 rmtree 掉磁盘目录。

SQLAlchemy ORM + 仓储 (infrastructure/knowledge/)

DocumentModelChunkModelmodels.py)映射到 documents / chunks,与其余模型注册在同一个 Base.metadata 上。documents_ftsBase.metadataafter_create DDL 钩子(ddl.py)创建,使 create_all 与迁移都会建出它。仓储面拆成两个模块以满足文件大小上限:

repository.py —— DocumentRepo(document 行 CRUD):

  • async upsert_document(d: Document) -> Document(按 (kind, resource_name, id) 插入或更新)
  • async list_documents(kind, resource_name, *, limit, offset) -> list[Document]
  • async count_documents(kind, resource_name) -> int
  • async get_document(kind, resource_name, doc_id) -> Document | None
  • async find_by_filename(kind, resource_name, project_id, original_filename) -> Document | None(重新上传的匹配键——读 metadata->>'original_filename';取代旧的 exists_source sha 查找)
  • async delete_document(kind, resource_name, doc_id) -> bool
  • async delete_resource(kind, resource_name) -> int

sqlite_index.py —— SqliteKnowledgeIndex(chunk + FTS5 + 可选 vec),绑定到一个 (kind, resource_name)

  • async upsert_chunks(doc_id, chunks, vectors|None) -> int(FTS5 + 可选 vec;替换既有行)
  • async delete_chunks(doc_id) -> None
  • async keyword_search(resource_name, query, top_k) -> Sequence[Passage](bm25)
  • async vector_search(resource_name, vector, top_k) -> Sequence[Passage](sqlite-vec KNN)

vec_chunks 的读写限定在 infrastructure/knowledge/vec_index.pysqlite_vec 的唯一 importer);chunk 文本删除 + FTS5 在 sqlite_index.py。应用层把这些仓储加上 grep.py 与 embedder 客户端(infrastructure/knowledge/embeddings.py)组合在共享检索门面 KnowledgeRetrievalapplication/knowledge/retrieval.py)之后,由它持有 keyword↔vector 的决策(包括带标注的 vector→keyword 回退)。

磁盘布局

~/.coffer/
├── coffer.db                       # resources / documents / chunks / documents_fts / vec_chunks / audit
└── knowledge/
    └── <kb-name>/
        ├── docs/
        │   └── <doc-id>.md         # normalized markdown = truth (YAML frontmatter + body)
        └── raw/
            └── <doc-id>.<ext>      # original upload (provenance / re-convert)

没有逐语料的 index/text/chroma/ 目录 —— 所有索引都在 coffer.db 里。infrastructure/knowledge/paths.py唯一构造这些路径的模块:

  • knowledge_root() -> Path —— ~/.coffer/knowledge/(测试可经 COFFER_KNOWLEDGE_ROOT 覆盖)
  • kb_dir(name) -> Path / docs_dir(name) -> Path / raw_dir(name) -> Path
  • doc_path(name, doc_id) -> Path / raw_path(name, doc_id, ext) -> Path

Markdown frontmatter

每个 docs/<doc-id>.md 自我描述:

yaml
---
title: Architecture Notes
source_filename: architecture.docx
source_format: docx
source_sha256: 9f8e…
converter: markitdown
source_mode: converted
---

单一 re-index 例程(application/knowledge/reindex.py,与 memory 共享)

所有写路径(ingest、重新上传、编辑、reindex 扫描)都汇入一个幂等例程(Reindexer)。逐 store 的 asyncio 锁(application/knowledge/locks.pyStoreLocks)串行化对同一 store 的写入,使并发 ingest/编辑不会交错更新索引:

compute content_sha256 of the new markdown body
 ├ unchanged + not embed_pending → true no-op (skip)
 ├ unchanged + embed_pending     → retry JUST the embed: re-chunk in memory,
 │                                 embed, upsert ONLY vec_chunks (no FTS / chunk
 │                                 rewrite ⇒ no churn) → clear embed_pending
 └ changed                       → delete old chunks / documents_fts / vec_chunks rows
                                 → markdown-aware chunk
                                 → if vector enabled: embed → write vec_chunks
                                 → write chunks + documents_fts
                                 → upsert documents row (bump updated_at)
                                 → audit KB_DOCUMENT_UPDATED (or _INGESTED on first index)

coffer kb reindex <name> 重新扫描 docs/ 目录找增量,逐文件运行该例程,从文件重建全部 SQLite 状态。该扫描还会清理 markdown 文件被带外删除的 documents 行,计入可选的 ReindexResult.documents_removed;因 embed 降级而只做了 keyword 索引的文档计入可选的 documents_degraded

降级 embed —— 解耦的重试状态(KB8)。 当 embed 降级(embedding provider 不可用 / EngineUnavailable)时,该例程对文档只做 keyword 索引,保留真实的 content_sha256,并置上专用的持久化标志 embed_pending。这把 no-op 闸门与重试状态解耦:降级文档不再在每次扫描时被重新切块 + 重写 FTS(旧设计用空字符串哨兵覆盖 content_sha256,导致永不匹配、把整个降级语料反复 churn)。下一次对账看到 embed_pending重试 embed —— 在内存里重新切块、只 upsert vec_chunks(chunk/FTS 行不动)—— 然后清除该标志。embed_pending 只跟踪 embedding provider 调用失败;它与 sqlite-vec 扩展是否可用正交(vec 表不可用属于 embedded=True,在查询时由引擎内部的 keyword 回退处理 —— 不作为响应标志暴露,见 ADR-034)。KB 将持久化的计数暴露为 KnowledgeBaseMetrics.documents_degraded,因此任意一次读取(list / get / search / grep)观察到的降级都可见,而不仅是显式的 POST /reindex

级联与完整性规则

动作效果
删除一个 Document移除 docs/<id>.md + raw/<id>.<ext>;删除其 chunks/documents_fts/vec_chunks 行;删除 documents 行;审计 KB_DOCUMENT_DELETED
删除一个 KBon_delete 钩子:delete_resource(kind, name)(documents + chunks + fts + vec);rmtree(kb_dir(name));删除 resources 行;审计 RESOURCE_DELETED 带 KB 快照。
重命名 KB禁止(Resource 名不可变;框架强制)。
修改 chunk_size / chunk_overlap允许 → 对语料 re-chunk + re-index。
修改 embedding 模型 / dimensions允许 → 对语料重新 embedding(宽度变化时重建 vec_chunks)。
编辑文档的 markdown经编辑 API 或 MCP edit_documentsource_mode = edited → reindex 例程。在外部编辑器中对磁盘文件的编辑由读取时惰性重建索引拾取(content_sha256 漂移 ⇒ 同一例程)。
重新上传变更后的 source在 store 内按 original_filename 匹配 → 就地更新同一文档(复用 ULID,覆盖 docs/+raw/,只保留最新一份原件,source_modeconverted),需 replace=true;字节相同的重新上传是 no-op。
重转换文档仅当 source_mode == converted 时允许;editedReconversionBlocked
经 MCP 新增 / 编辑 / 删除agent 调用 coffer__add_document / edit_document / delete_document;与 REST 同一套服务路径,以 agent 为 actor 审计。

新增审计事件

AuditEventType 是可扩展的 StrEnum。新增:

何时发出
"kb_document_ingested"新文档首次索引后
"kb_document_updated"reindex 例程对变更文档重建索引后(编辑 / 重新上传)
"kb_document_deleted"文档删除后
"kb_reindexed"一次完整 coffer kb reindex 后(携带逐文档计数)

KB 生命周期由 kind 无关核心的 resource_created / resource_deleted 覆盖。内建 MCP 工具调用记录到 mcp_invocations(仅工具名 + who/when/duration/outcome)。

Importlinter 契约(新增或修订)

契约效果
1 —— 分层架构不变
2 —— infrastructure ↛ surfaces不变
3 —— domain 纯净不变;domain/knowledge/* 只 import 标准库 + Pydantic
4 —— keyring 限定于 infrastructure不变(embedding credential_ref 经凭据模块解析)
5 —— 禁止跨 kind import扩展:加入 coffer.{application,surfaces.http}.knowledge_base;共享的 coffer.{domain,infrastructure}.knowledge 基底豁免(kind 无关)
6 —— kind 无关核心 ↛ kind 专属扩展:把 knowledge_base 模块加入 forbidden_modules
7(取代旧 LlamaIndex 规则)—— 引擎限定coffer.application.*coffer.domain.* 不得 import markitdowndoclingsqlite_vecopenaifastembed;只有 coffer.infrastructure.knowledge.* 可以

线上契约(REST)

位于 contracts/api.openapi.yaml。要点(全应用统一错误包络 {error:{code,message,details}}):

  • POST /api/v1/knowledge_bases —— 创建 KB
  • GET /api/v1/knowledge_bases —— 列出 KB
  • GET /api/v1/knowledge_bases/{name} —— 获取单个 KB
  • POST /api/v1/knowledge_bases/{name}/documents —— multipart 上传 + ingest(任意格式)
  • GET /api/v1/knowledge_bases/{name}/documents —— 分页列表,支持可选的大小写不敏感标题过滤 q(服务端在分页前应用;total 反映过滤后的计数);每行携带绝对 path + 所在文件夹 folder_path
  • GET /api/v1/knowledge_bases/{name}/documents/{doc_id} —— 只读 markdown 正文 + frontmatter + 绝对 path + folder_path
  • PUT /api/v1/knowledge_bases/{name}/documents/{doc_id} —— 经 API 编辑 markdown(置 source_mode=edited,重建索引);UI 为只读,其余编辑经外部编辑器或 agent MCP 进行(由读取时惰性重建索引拾取)
  • POST /api/v1/knowledge_bases/{name}/documents/{doc_id}/reconvert —— 从 raw/ 重跑转换(一旦手工编辑过即被 RECONVERSION_BLOCKED 拦截)
  • POST /api/v1/knowledge_bases/{name}/documents/{doc_id}/update-source —— 从被跟踪的外部 source_path 就地重新 ingest(一旦手工编辑过即被拦截)
  • DELETE /api/v1/knowledge_bases/{name}/documents/{doc_id} —— 删除单个文档
  • POST /api/v1/knowledge_bases/{name}/reindex —— 重扫描 + 从文件重建索引
  • POST /api/v1/knowledge_bases/{name}/check-sources —— 检测外部原件已变化的被跟踪文档(unchanged/changed/missing;启用 auto_update_sources 时自动刷新)
  • POST /api/v1/knowledge_bases/{name}/search —— {query, top_k?} → 排序 passage(无 mode 输入,无 fallback 回显 —— 检索模式是内部的,见 ADR-034)
  • POST /api/v1/knowledge_bases/{name}/grep —— {pattern, max_matches?} → 文件/行命中
  • GET /api/v1/knowledge_bases/{name}/metrics —— 计数 + 已建索引模式 + 磁盘字节数

kind 无关的 /api/v1/resources/... 端点对 KB 继续可用(list / get / delete / enable / disable)。