数据截至 (上游 commit 9ad3e4e49aa3)
把文档装进库:索引与切分管线
30 秒导读: 这一章讲 Kotaemon 的摄入路径——你上传一个 PDF/Word/网页,它是怎么被读成文本、切成小块(chunk)、再同时写进两套存储(一套供全文检索、一套供向量检索),并在 SQL 里记下"哪个文件产生了哪些块"的。读完你能完整讲清"一份文件从上传到可被检索"的每一步。检索和问答本身不在这章(见 03-hybrid-retrieval 和 04-qa-citation)。
1. 这是什么(零基础也能懂)
一句话定义: 索引管线(indexing pipeline)是把"人能看的文件"变成"机器能检索的数据"的流水线。
它解决什么问题? RAG(检索增强生成,先查资料再让大模型答)要能工作,前提是资料已经被切成小块、算好向量、存进库里。你不可能每次提问都现场去读一遍 100 页的 PDF。所以必须有一步"离线预处理":读文件 → 切块 → 入库。这一步就是本章的主角。
给谁用? Kotaemon 是一个开源的 RAG 问答 UI。终端 用户在网页上点"上传文件",背后就触发这条管线。开发者也能通过配置换掉其中任意一环(换 PDF 解析器、换切块大小、换向量库)。
用起来什么样? 用户视角其实很朴素——拖一个文件进去,界面上滚动打印:
Indexing [1/1]: report.pdf
=> Converting report.pdf to text
=> Converted report.pdf to text
=> [report.pdf] Processed 42 chunks
=> [report.pdf] Created embedding for 42 chunks
=> Finished indexing report.pdf
这些正是管线在 stream() 里一步步 yield 出来的调试消息(libs/ktem/ktem/index/file/pipelines.py:648-655)。文件从此"进了库",可以被提问检索到。
一句话直觉: 把索引想成图书馆入库——先把书拆成一页页(切块),既把书放进书架(向量库,按"意思相近"找),又在目录卡片盒里登记(全文库 + SQL 表,按"字面词"找、按"哪本书"找)。同一批内容,两种索引方式并存。
2. 顶层全景(它大概怎么转)
整条路径由两层管线组成:上层 IndexDocumentPipeline 是工厂/调度,负责"这份文件该用哪套处理法";下层 IndexPipeline 是干活的,负责单个文件的读、切、存。
上传的文件路径 / URL
│
┌───────────────▼────────────────┐
│ IndexDocumentPipeline (工厂) │ selectreader_mode 选 reader
│ route(file_path) │ 按扩展名/URL 分派
└───────────────┬────────────────┘
│ 为每 个文件造一个
▼
┌────────────────────────────────┐
│ IndexPipeline (单文件流水线) │
│ │
│ ① loader.load_data() 读成文本 │
│ ② splitter 切成 chunk │
│ ③ 双写入库 │
└───────┬───────────────┬────────┘
│ │
┌────────────▼───┐ ┌───────▼───────────┐
│ doc_store(DS) │ │ vector_store(VS) │
│ 存原文,供全文 │ │ 存 embedding,供 │
│ 检索 │ │ 向量检索 │
└────────────────┘ └───────────────────┘
│ │
└───────┬───────┘
▼
SQL: Source 表(登记文件)
Index 表(记 file↔chunk 关系)
怎么读这张图: 从上到下就是一份文件的生命周期。左右两个存储是同一批 chunk 的两份索引,最底下的 SQL 表是把它们串起来的"账本"。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
IndexDocumentPipeline | 工厂:按 reader_mode 和文件类型选管线 | libs/ktem/ktem/index/file/pipelines.py:659 |
IndexPipeline | 单文件流水线:读→切→双写 | libs/ktem/ktem/index/file/pipelines.py:330 |
TokenSplitter | 按 token 数把文本切成 chunk | libs/kotaemon/kotaemon/indices/splitters/__init__.py:10 |
VectorIndexing | 真正执行"写 doc_store + 写 vector_store" | libs/kotaemon/kotaemon/indices/vectorindex.py:21 |
BaseFileIndexIndexing | 索引管线的抽象契约,声明能拿到哪些资源 | libs/ktem/ktem/index/file/base.py:36 |
| Source / Index 表 | SQL 里登记文件、记 chunk 归属 | libs/ktem/ktem/index/file/index.py:91,115 |
主线走一遍(高层): 文件路径进来 → 工厂 route() 挑出合适的 reader 和 splitter,拼出一个 IndexPipeline → 这个管线读文件成文本、切成 chunk → 把每个 chunk 同时写进 doc_store 和 vector_store,并在 Index 表记下"这个 chunk 属于这个文件" → 收尾统计 token 数 → 文件可被检索。
3. 核心原理(逐个机制,由浅入深)
3.1 双层管线:工厂选料,流水线干活
要解决的小问题: 不同文件类型(PDF、Word、图片、网页)需要不同的读取器;而且同一种 PDF,用户可能想用不同引擎解析(开源默认 vs Adobe vs Azure)。把"选择逻辑"和"执行逻辑"混在一起会很乱。
思路: 分成两层。上层 IndexDocumentPipeline 只做决策——它自己不读文件,而是像工厂一样造出一个配好 reader 和 splitter 的下层 IndexPipeline。文档注释里说得很直白:
"This method is essentially a factory to decide which indexing pipeline to use." ——
libs/ktem/ktem/index/file/pipelines.py:660-661
上层入口 stream() 遍历所有待索引文件,对每个文件先 route() 出一条管线,再委托它干活(pipelines.py:836-841):
# 示意,贴近源码。重点看:route 造管线,再 yield from 委托
pipeline = self.route(file_path) # 选料:造一条单文件管线
file_id, docs = yield from pipeline.stream( # 干活:委托它读→切→存
file_path, reindex=reindex, **kwargs
)
用 yield from 把下层管线的进度消息透传给 UI——这就是你在界面上看到那些 => Processed N chunks 滚动打印的原因。整个 stream() 用异常包裹每个文件,单个失败不会中断整批(pipelines.py:852-864),失败的文件在返回列表里对应位置记为 None + 错误消息。
3.2 选 reader:reader_mode + 按扩展名分派
要解决的小问题: ".pdf" 该交给谁读?".docx" 呢?URL 又该怎么办?
两步决策:
第一步——reader_mode 决定 PDF 用哪个引擎。 IndexDocumentPipeline 有个 reader_mode 参数(默认 "default"),由一个 @Param.auto 惰性构建的 readers 字典承接(pipelines.py:675-711)。它先拷贝一份默认扩展名映射 KH_DEFAULT_FILE_EXTRACTORS,再按 reader_mode 覆盖 PDF(及图片)对应的 reader:
| reader_mode | 效果 |
|---|---|
default | 用开源默认(PDF 走 PDFThumbnailReader) |
adobe | PDF 换成 adobe_reader(带图/表抽取) |
azure-di | PDF 换成 azure_reader(Azure 文档智能) |
docling | PDF 换成 docling_reader |
paddle-struct | PDF + 各图片格式换成 paddle_struct_reader |
paddle-vl | PDF + 各图片格式换成 paddle_vl_reader(视觉大模型解析) |
@Param.auto(depends_on="reader_mode") 是组件模型里的机制:当 reader_mode 变了,readers 会自动重算,不用手写刷新逻辑。
默认映射本身覆盖常见办公格式(libs/kotaemon/kotaemon/indices/ingests/files.py:48-64):.xlsx→PandasExcelReader、.docx/.pptx/.doc/图片→unstructured、.html→HtmlReader、.pdf→PDFThumbnailReader、.txt/.md→TxtReader 等。
第二步——route() 按扩展名/URL 取 reader。 真正分派在 route()(pipelines.py:757-803):
# 示意,贴近源码。重点看:URL 走 web_reader,否则按扩展名查表,查不到兜底 unstructured
if self.is_url(file_path):
reader = web_reader # http(s):// → 网页读取器
else:
ext = file_path.suffix.lower() # 取扩展名 .pdf/.docx…
reader = self.readers.get(ext, unstructured) # 查表,查不到就用 unstructured 兜底
选好 reader 后,route() 顺手把 TokenSplitter 一起装配进新造的 IndexPipeline,并把所有资源(Source/Index 表、VS、DS、FSPath、user_id、embedding)透传下去(pipelines.py:784-801)。
3.3 切块:TokenSplitter 怎么把文本变成 chunk
要解决的小问题: 一份长文档不能整块塞进检索——太大,向量表 达不精确,也超模型上下文。要切成大小合适、还带一点重叠的小块。
思路: 按 token 数(不是字符数、不是行数)切,让每块大小可控。默认参数在 route() 里(pipelines.py:786-791):
| 参数 | 默认值 | 含义 |
|---|---|---|
chunk_size | 1024 | 每块目标 token 数 |
chunk_overlap | 256 | 相邻块重叠 token 数(避免切断语义) |
separator | "\n\n" | 首选切分点(段落) |
backup_separators | ["\n", ".", ""] | 段落切不动时的退路 |
chunk_size / chunk_overlap 可被 index 配置或 flowsettings.py 里的开发者设置覆盖(pipelines.py:763-766,回退链见 dev_settings() at pipelines.py:57-76)。
TokenSplitter 本身是个薄封装,底层复用 llama-index 的 TokenTextSplitter(libs/kotaemon/kotaemon/indices/splitters/__init__.py:10-28)。它继承自 DocTransformer,所以是一个可直接当函数调用的组件——self.splitter(text_docs) 就切完了。
切块发生在 handle_docs()(pipelines.py:374-385)。这里有个关键细节:只有文本类文档被切,图片/缩略图/表格等非文本内容原样保留:
# 示意,贴近源码。重点看:文本才切,其余原样,最后合并成待入库列表
if self.splitter:
all_chunks = self.splitter(text_docs) # 只切纯文本
else:
all_chunks = text_docs
# …给每个 chunk 关联同页缩略图的 doc_id(供检索时展示原图)…
to_index_chunks = all_chunks + non_text_docs + thumbnail_docs # 合并
handle_docs() 先按 doc.metadata["type"] 把读出来的文档分成 text / thumbnail / 其它三类(pipelines.py:360-367),再只对 text 那一坨切块。
3.4 双写入库:同一批 chunk,两套存储
这是整章最核心的一步:同一批 chunk 会被写进两个地方,分别服务两种检索方式。
为什么要两套? 检索章(03)会讲"混合检索":既要按语义找(向量库),又要按关键词字面找(全文库)。所以入库时就得两份都备好。
谁来写? IndexPipeline 委托 VectorIndexing(vectorindex.py:21)。它由 @Node.auto 惰性构建,把 VS/DS/embedding 一并注入(pipelines.py:348-352)。VectorIndexing 暴露两个独立方法:
add_to_docstore(docs)—— 把原文 chunk 加进 doc_store,供全文检索(vectorindex.py:79-82)。add_to_vectorstore(docs)—— 先对 chunk 算 embedding,再把向量加进 vector_store,ids 用 chunk 的doc_id(vectorindex.py:84-93):
# 示意,贴近源码。重点看:先算 embedding,再连同 doc_id 一起写向量库
embeddings = self.embedding(docs) # 一批 chunk → 一批向量
self.vector_store.add(
embeddings=embeddings,
ids=[t.doc_id for t in docs], # 用 chunk 自己的 id 当向量 id
)
注意 doc_store 存的是完整原文(检索命中后要拿回原文给 LLM 看),vector_store 存的只是向量 + id;两边靠同一个 doc_id 对齐。这就是为什么向量库命中后还要回 doc_store 拿原文(见 03)。
独立方法给了并发的余地。 IndexPipeline.handle_docs() 把两次写入拆成两趟(先全部写 docstore、再分批写 vectorstore),并允许把向量写入丢到后台线程跑(pipelines.py:414-421,由 run_embedding_in_thread / "quick index mode" 控制),这样计算 embedding 的耗时不阻塞 docstore 落库。
VectorIndexing.run()(vectorindex.py:95-113)则是"一把梭"的入口:接收 str 或 Document 列表,依次 add_to_vectorstore → add_to_docstore → write_chunk_to_file。ktem 的文件管线没直接用 run(),而是调更细粒度的 add_to_* 方法以便分批和记账。
3.5 SQL 账本:Source 表与 Index 表
要解决的小问题: 向量库里躺着一堆 chunk 向量,doc_store 里躺着一堆 chunk 原文——但"哪些 chunk 属于 report.pdf?""哪些是向量、哪些是原文?"这些关系存哪?答案是 SQL。
两张表(每个 index 一套,表名带 index id):
| 表 | 一行代表 | 关键列 |
|---|---|---|
| Source | 一个上传的文件 | id(文件 id)、name、path(文件内容的 sha256)、size、note(JSON) |
| Index | 一条 file→chunk 关系 | source_id(文件 id)、target_id(chunk 的 doc_id)、relation_type |
表定义在 libs/ktem/ktem/index/file/index.py:Source at :91-114(private 变体 at :63-89),Index at :115-126。
relation_type 是精髓。 同一个 chunk 会在 Index 表里产生两行:一行 relation_type="document"(对应 doc_store),一行 relation_type="vector"(对应 vector_store)。写入分别发生在:
handle_chunks_docstore():写 doc_store 后,记relation_type="document"(pipelines.py:426-443)。handle_chunks_vectorstore():写 vector_store 后,记relation_type="vector"(pipelines.py:445-464)。
# 示意,贴近源码。重点看:每个 chunk 记一行 file→chunk 关系,类型区分它在哪个库
nodes.append(
self.Index(
source_id=file_id, # 属于哪个文件
target_id=chunk.doc_id, # 是哪个 chunk
relation_type="document", # 在 doc_store(vector 分支则为 "vector")
)
)
这个账本让下游能干两件事:
- 检索时反查 chunk。 拿到用户选的文件 id,查 Index 表
relation_type=="document"就得到该文件所有 chunk 的 id,把检索范围限定在这些文件内(检索侧用法见pipelines.py:148-153,03详述)。 - 删除时精确清理。
delete_file()按relation_type把 chunk id 分成 vs_ids / ds_ids 两拨,分别去 vector_store 和 doc_store 删干净,再删 SQL 行(pipelines.py:574-597)。