Skip to content

分块与索引机制

本页说明四类内容(note/clip/knowledge/document)如何被切成检索单元并写入三套索引:统一 chunk 管线、表格行结构化索引、FTS5 关键词索引、sqlite-vec 向量索引,以及向量前拦质量门、内容版本号与索引回填的幂等设计。这是融合检索与 RAG 问答的数据底座。

索引全景

索引形态粒度代码位置
FTS5 关键词索引content_fts 虚拟表,BM25 排名文档级,不切块app/services/fts_index.py
向量索引embeddings 表 + sqlite-vec 影子表 KNN子块级(短文档一文档一向量)app/services/chunking.py / vec_index.py
表格行结构化索引row:: 行条目,进同一张向量表表格数据行级app/services/chunking.py

三类内容通道共用同一套分块与行条目管线;空间隔离不在索引层做(个人/团队都进索引),统一在检索侧按内容行收口(tenant_id 过滤)。

统一 chunk 管线

app/services/chunking.py 是四类内容共用的纯函数切块器,父子块结构:

  • 阈值:正文 ≤ CHUNK_SIZE_THRESHOLD(1500 字)保持一文档一向量,小文档行为完全不变;超过才切块。
  • 父子块:子块目标 ~600 字(CHILD_TARGET,去向量化/检索命中),父块目标 ~1800 字(PARENT_TARGET,命中后作上下文窗口),兄弟子块间带 80 字 overlap(CHILD_OVERLAP)。父块全文冗余在子块行的 parent_text 列,检索端零回表直接取窗口。
  • 切块序:Markdown 标题边界优先(标题行并入其后内容段),小 section 贪心合并成父块,超长 section 按段落 → 句子 → 硬切再切;可无损拼回,同输入两次调用输出完全一致。
  • 命名:子块 content_id = {doc_id}::chunk::{p}.{c}(p=父序号、c=父内子序号),检索端据此把块归属回原文档。
  • 长文档另落一行「文档级入口行」(content_id 无后缀,嵌入文本 = 全文前 1000 字),作全局提问的语义召回锚点。

表格行结构化索引(row::)

Markdown 表格的每个数据行额外产出一条行级条目向量,四类内容共用:

  • 条目文本 = 最近章节标题前缀 + 「列名: 值」按 | 拼接(extract_table_row_entries);
  • 命名空间{doc_id}::chunk::row::{n} 独立前缀,接在子块后排,检索端当普通块行处理零改动;
  • 限帽:单文档 TABLE_ROW_ENTRY_CAP = 500 行,超限一行不产、整体回落整表切块(防病态大表撑爆向量行数);
  • 解决的问题:「低频编号 + 高区分度字段」的表格行整表切块后行信号被稀释,稳定进不了 prompt——行条目补的是独立召回精度,整表仍在父块里保上下文;
  • 09-19 起 XLSX/DOCX 表格经管道表抽取(见文档解析与 OCR)后同样产出 row:: 条目。

FTS5 BM25 关键词索引

app/services/fts_index.py(移植自腾讯 TencentDB-Agent-Memory 的 FTS5 设计):

  • 单表 content_fts,只索引分词后的 title_tok / content_tok 两列;content_id/content_type/user_id/tenant_id 等 UNINDEXED 随行,用于空间过滤与回填比对,不参与 BM25 排名。文档级索引,不切块——关键词通道本来就是文档级,块是向量层的事。
  • 中文分词:写读两侧同一口径——jieba cut_for_search(搜索引擎模式,整词 + 子词都进索引);读侧加中文停用词过滤 + OR 连接 quoted 短语。jieba 缺失时降级 bigram + 整词方案(记一次 warning 后缓存降级标记)。
  • BM25 的 IDF 需要库量级才生效:只有几篇文档时 IDF≈0、BM25 全体贴地,量级差/归一塌缩类问题根本不发生——复现与校准此类问题需垫 ~300 篇量级的内容。文档进索引要 doc_status='active'extraction_status='success';FTS 不加向量通道那道质量门(关键词召回垃圾代价低)。
  • 扩展不可用时所有读写静默跳过,检索落回 ILIKE 老路,功能不断。

sqlite-vec KNN 向量索引

app/services/vec_index.pyembeddings 表的 KNN 加速层:

  • 按维度分影子表 vec_embeddings_{dim}(vec0 虚拟表,cosine 距离),user_id 作 partition key;扩展加载失败置全局不可用标记,读写静默跳过,检索落回暴力余弦,功能不断。
  • 全库统一维度:嵌入模型全库统一 bge-m3 1024 维(OLLAMA_EMBED_MODELapp/core/config.py),统一维度是影子表按维度分表能成立的前提。
  • 团队口径:向量行不加 tenant 列(团队内容向量 user_id 记创建者),KNN 命中后回内容行按 tenant_id = T 收口(_tenant_scope_filter);个人口径按 user_id 分区过滤。
  • 检索侧两路读方:vec_knn(MATCH 最近邻)与 vec_scan_distances(全分区距离扫描,区间过滤类读方用);返回 None 都代表「不可用,落回暴力路径」。

向量前拦质量门

向量通道入口处有一道纯硬信号的前拦门(app/services/note_embedding_service.py):

  • 正文 ≥ 200 字MIN_EMBED_TEXT_LEN,strip 后字符数;图片文档 OCR 文本常不足 200 字,放宽到 MIN_EMBED_TEXT_LEN_IMAGE = 20);
  • 状态门:note/clip/knowledge 要 status='active';文档要 doc_status='active'extraction_status='success'
  • 门只拦向量通道,FTS 关键词通道不加门;被拦记 info 计数日志;
  • 个人/团队内容同口径过门,隔离在检索侧内容行收口,不加 tenant 前拦。

内容版本号

GET /system/content-versionapp/api/v1/endpoints/system.pysite_knowledge.content_version)返回当前空间的内容版本戳,准实时刷新。前端用它驱动查询缓存失效——内容变更后列表/统计自动重拉,不用手动刷新页面。

索引重建与回填的幂等设计

所有索引层都允许「随时重跑、重跑无害」:

机制幂等口径
FTS 回填 backfill_fts_index启动时扫四表 active 行,按 updated_at 比对补齐/刷新;脏行由检索端回表校验容忍
向量影子表回填 backfill_vec_index启动时扫 embeddings 表补缺失索引行,已存在即跳过,可重复跑
分块回填判存 expected_chunk_count比对实际条数与「子块数 + 行条目数 + 1」的预期分块数——只看第 0 块在不在会把半截覆盖判成已覆盖
嵌入写入「先算后写」事务化替换:任一块算不出向量或处于 mock fallback 就不动库,绝不落半截覆盖
存量表格回填marker extract_md_tables_v1 全成才落,有差异才更新,见文档解析与 OCR

共同哲学:索引是影子、内容行是事实源;脏行容忍在检索端收口,回填在启动时一次性补齐,失败下轮续跑。

下一步

基于 AGPL-3.0 协议发布。