跳到主要内容

Khoj 索引管线 — 文档如何变成可搜索的向量块

本章讲入库:一个文件从磁盘到"一堆带向量的文本块"要经过哪些步骤。核心三件事:切块编码增量更新

1. 为什么要切块(先建直觉)

嵌入模型(embedding model)一次能"读懂"的文本长度有限——Khoj 用的双编码器 thenlper/gte-small 上下文只有几百 token。一篇长笔记直接整篇编码,会把细节糊成一团"平均意义",搜"冥想"时匹配不到埋在第 8 段的那句话。

所以要"切块":把长文切成语义相对完整的小段,每段单独一个向量。检索时命中的是"段",粒度刚好——够小能精准定位,够大能保留上下文。

Khoj 的切块目标是 256 token 一块(split_entries_by_max_tokensmax_tokens=256,见 src/khoj/processor/content/text_to_entries.py:61)。

2. 切块用的是"递归字符切分",不是硬切

最笨的切法是"每 256 token 一刀",但那会把句子从中间劈开。Khoj 用 LangChain 的 RecursiveCharacterTextSplitter,按优先级找分隔符:先试段落,不行退句子,再不行退词,最后才退字符。

# 示意,非源码 —— 演示切块的"优先级降级"思路
separators = ["\n\n", "\n", "!", "?", ".", " ", "\t", ""]
# 段落 换行 ————句子———— 词 制表 字符
# 切分器从左到右尝试:能用段落边界切就用段落,
# 一段还是太长(>256 token)就在句子边界切,以此类推。
# 重点看:长度是用"词数"衡量的,不是字符数。

真实实现里,长度函数被换成了"按空格分词后的词数",而不是默认的字符数(src/khoj/processor/content/text_to_entries.py:72-78,length_function=lambda chunk: len(TextToEntries.tokenizer(chunk)),tokenizer 就是 text.split())。chunk_overlap=0 表示块之间不重叠。

分隔符顺序 ["\n\n", "\n", "!", "?", ".", " ", "\t", ""] 正是"段落 > 句子 > 词 > 字符"的降级链(同文件 :74)。

3. 关键巧思:给非首块补"标题前缀"

切块有个隐患:第一块通常带着文档标题("# 睡眠笔记 ## 冥想"),但从第二块起,内容就"孤儿"了——一段讲冥想细节的文字,脱离了"这是睡眠笔记里冥想那节"的上下文,向量就会偏。

Khoj 的解法:从第 2 块起,把标题的末尾 100 字符补回块首

# 示意,非源码 —— 给非首块补标题,让每块都自带上下文
if chunk_index > 0 and entry.heading:
snipped_heading = entry.heading[-100:] # 标题太长只留末尾 100 字符
compiled_entry_chunk = f"{snipped_heading}\n{compiled_entry_chunk}"
# 重点看:留"末尾"是因为越靠近的子标题(## 冥想)比顶层文件名更能定位语义

真实实现在 text_to_entries.py:112-117。注释点明了取舍:"Keep last 100 characters of heading as entry heading more important than filename"。

这个"标题前缀"在更上游就开始铺垫了。以 Markdown 为例,convert_markdown_entries_to_maps 会把文件名当作顶层标题拼进 compiled 字段(src/khoj/processor/content/markdown/markdown_to_entries.py:171-181):

prefix = "# sleep-notes.md\n#" # 文件名做 H1,原标题降一级
compiled = prefix + 原始条目文本 # 这个 compiled 才是被编码的东西

注意区分两个字段:raw 是原始文本(展示给用户),compiled 是"文件名 + 标题 + 正文"的拼接体——真正送去算 embedding 的是 compiled(见 §4)。

4. 编码:双编码器把每块变成一个向量

"双编码器(bi-encoder)"是指:文档和查询各自独立编码成向量,之后只需算向量间距离。它快——文档向量可以离线算好存起来,查询来了只编码查询这一条。(对照:交叉编码器要把"查询+文档"拼一起过一遍模型,准但慢,见 02-retrieval.md。)

Khoj 的双编码器封装在 EmbeddingsModel(src/khoj/processor/embeddings.py:29),默认模型 thenlper/gte-small(见 SearchModelConfig.bi_encoder 默认值,src/khoj/database/models/__init__.py:558)。它有两个编码入口,刻意区分"编文档"和"编查询":

方法用途默认 encode 参数位置
embed_documents批量编入库的块show_progress_bar=Trueembeddings.py:94
embed_query编单条查询show_progress_bar=Falseembeddings.py:53

两者都默认 normalize_embeddings=True(embeddings.py:40-41)——归一化后,余弦相似度等价于点积,查询更快。模型还支持三种后端:本地 SentenceTransformer、HuggingFace 推理端点、OpenAI 兼容端点(embed_query 里的分支,embeddings.py:53-58),本地是默认。

入库时的调用点在 update_embeddings(text_to_entries.py:183):

# 真实源码风 —— text_to_entries.py:181-183
data_to_embed = [getattr(entry, key) for entry in entries_to_process] # key 默认 "compiled"
embeddings += self.embeddings_model[model.name].embed_documents(data_to_embed)

即:取每个待处理块的 compiled 文本,批量编码。 state.embeddings_model 是一个 {模型名: EmbeddingsModel} 字典,在启动时按 SearchModelConfig 初始化(src/khoj/configure.py:261-277)。

5. 最精妙的一环:哈希增量更新

这是整个索引管线最值得学的设计。问题: 你改了一篇 500 块的长文里的一个字,难道要把 500 块全部重新编码?向量编码是最贵的一步,全量重算既慢又费电。

Khoj 的解法:内容哈希做"指纹",指纹没变就复用旧向量。

每个块的指纹 = MD5(compiled 文本)

本次要索引的块: {h1, h2, h3, h4} ← 当前文件切出来的块的哈希
库里该文件已有的:{h1, h2, h4, h5}

需要编码的 = {h3} ← 只有新指纹才送编码器
需要保留的 = {h1, h2, h4} ← 指纹已存在,啥都不做
需要删除的 = {h5} ← 文件里没了,从库里删掉

哈希函数本身很简单(text_to_entries.py:35-37):

# 真实源码 —— text_to_entries.py:36-37
def hash_func(key: str) -> Callable:
return lambda entry: hashlib.md5(bytes(getattr(entry, key), encoding="utf-8")).hexdigest()

"只编码新块"的核心逻辑在 update_embeddings(text_to_entries.py:167-183):先按文件把当前块的哈希分组,查库里已存在的哈希,用集合差 hashes_for_file - existing_entry_hashes 得到"要处理的新块",只对这批调 embed_documents

"删除消失的块"在同函数尾部(text_to_entries.py:249-254):对每个文件,库里已有哈希 - 本次哈希 = 要删的块

边界提醒: 由于指纹是 compiled(含文件名 + 标题前缀)的 MD5,改动标题或移动文件会让该文件所有块的指纹变化,从而触发全量重编码——这是"标题前缀"设计的代价。(inferred:代码未直接注释此点,但由"指纹含标题前缀 + 增量按指纹判定"两个事实推出。)

此外 mark_entries_for_update(text_to_entries.py:265-323)是旧的、基于文件的增量路径(用 -1 标记新块给后续编码),配合 text_search.py:40compute_embeddings 把向量存成 torch 文件;当前生产路径已改为上面的 pgvector/数据库路径(compute_embeddings/load_embeddingssrc/khoj/ 内已无调用点,inferred by grep)。

6. 落库:批量写 + 顺带抽日期

编码完的向量被组装成 Entry 对象批量写库(bulk_create,批大小 min(200, N),text_to_entries.py:202-228)。每个 Entry 存的关键字段:

字段是什么备注
embeddings向量pgvectorVectorField(dimensions=None)(models/__init__.py:787)
raw原始文本展示给用户看
compiled编码用文本文件名 + 标题 + 正文
heading标题(截断 1000 字符)text_to_entries.py:216
hashed_value内容指纹增量更新的 key
corpus_id同一原条目的所有块共享的 UUID用于检索去重(见 02-retrieval.md)
file_type / file_path / url来源元数据过滤与引用回链用

落库后还会从每块正文里抽出日期存进 EntryDates 表(text_to_entries.py:237-247),这样查询里的 dt>"2024-01-01" 才能按内容日期过滤(机制见 03-rag-and-boundaries.md)。


小结: 入库 = 按标题切条目 → 按 256 token 递归切块 → 给非首块补标题前缀 → 对 compiled 文本算 MD5 → 只编码新指纹 → 批量写进 pgvector。最值得带走的是"内容哈希做增量"这一招:它把"重新索引"从 O(全部块) 降到 O(改动块)。

下一章看这些向量怎么被查出来。