分块与索引机制
本页说明四类内容(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.py 是 embeddings 表的 KNN 加速层:
- 按维度分影子表
vec_embeddings_{dim}(vec0 虚拟表,cosine 距离),user_id作 partition key;扩展加载失败置全局不可用标记,读写静默跳过,检索落回暴力余弦,功能不断。 - 全库统一维度:嵌入模型全库统一 bge-m3 1024 维(
OLLAMA_EMBED_MODEL,app/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-version(app/api/v1/endpoints/system.py → site_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 |
共同哲学:索引是影子、内容行是事实源;脏行容忍在检索端收口,回填在启动时一次性补齐,失败下轮续跑。
下一步
- 文档解析与 OCR——文本层从哪来
- 笔记、剪藏与知识管理——四类内容的上层模型
- 标签档夹与融合检索——两路索引如何融合成搜索结果
- AI 对话与 RAG 问答——索引之上的问答链