数据截至 (上游 commit ae229ca894c0)
Khoj — 检索与 RAG 子系统架构与原理
30 秒导读: Khoj 是一个"个人第二大脑"——你把笔记(Markdown / PDF / Org / Notion…)交给它,它帮你用自然语言问,从你自己的资料里找答案。本文只讲它最核心的一块:检索(retrieval)与 RAG——文 档如何被切块、向量化、存进数据库,查询时又如何被召回、重排,最后作为"引用"喂给大模型来回答问题。
本套文档聚焦 Khoj 仓库里 search_type/、processor/embeddings.py、processor/content/、database/adapters/ 这几处组成的语义检索管线,不覆盖它的聊天 UI、Agent 编排、图像生成等其它子系统。
1. 这是什么(零基础也能懂)
一句话定义: Khoj 的检索子系统 = 一个建在 Postgres 上的私人语义搜索引擎——把你的文档切成小段、每段算一个"意义向量",查询时按"意义接近程度"把最相关的段落捞出来。
解决什么问题 / 给谁用:
假设你攒了几百篇笔记,想问"我去年关于睡眠的那些记录里,提到过哪些改善方法?"。传统关键词搜索(grep / Ctrl-F)只能匹配字面词,你写"改善方法"它就找不到写着"我试了冥想后好多了"的那条。语义搜索要的是"意思相近"而不是"字一样"——这正是 Khoj 检索层要做的事,然后它把找到的片段交给 LLM 组织成一段回答(这就是 RAG,检索增强生成)。
它能做什么:
- 把多种格式(Markdown、Org-mode、PDF、Notion、GitHub、纯文本、Word)统一切成"条目(entry)",每条带上它的标题层级。
- 为每个条目生成向量(embedding),存进 Postgres 的
pgvector向量列。 - 支持在查询里夹带过滤语法:按词(
+"必须有"/-"不要有")、按文件(file:"*.md")、按日期(dt>"2024-01-01")精确过滤。 - 两段式排序:先用双编码器快速召回一批,再用交叉编码器对这批做精排。
- 增量索引:文件没变的块复用旧向量,只为新增/改动的块重新编码。
用起来什么样:
最直接的入口是 HTTP GET /api/search(定义在 src/khoj/routers/api.py:46,函数 search):
GET /api/search?q=如何改善睡眠 -"咖啡" dt>"2024-01-01"&n=5&r=true
→ 返回一个 JSON 数组,每项是一个命中片段:
{
"entry": "# sleep-notes.md\n## 冥想\n我试了睡前冥想后好多了……",
"score": 0.11, # 越小越相关(余弦距离)
"corpus_id": "…uuid…",
"additional": { "file": "sleep-notes.md", "heading": "## 冥想", … }
}
参数直觉:q 是查询(可夹带过滤语法),n 要几条,r=true 表示"用交叉编码器重排一遍"。
一句话直觉/类比: 把它想成"给你的笔记建了一座图书馆"——切块是把书拆成一张张卡片,embedding 是给每张卡片贴上一个"主题坐标",查询是拿你问题的坐标去找最近的卡片,重排是让一位更挑剔的馆员把最靠谱的几张再排一次序。
2. 顶层全景(它大概怎么转)
整个子系统分两条时间线:离线的"索引"(把文档变成向量存起来)和在线的"检索"(拿查询去捞向量)。看懂这两条就看懂了大盘。
【索引时 · 离线】把文档变成可搜索的向量
你的文件 切块器 双编码器 Postgres
┌─────────┐ RecursiveCharacter ┌──────────────┐ ┌──────────────┐
│ .md/.pdf│──▶ TextSplitter(256 ──▶│ gte-small │──▶ │ Entry 表 │
│ .org/… │ token/块,带标题前缀) │ 生成向量 │ │ embeddings 列 │
└─────────┘ │ └──────────────┘ │ (pgvector) │
▼ └──────────────┘
哈希每块 → 只编码新块 ▲
(旧块复用已有向量) │增量:改动的块才重算
【检索时 · 在线】把查询变成向量去捞
查询串 q ─▶ 拆过滤器 ─▶ 双编码器编码 ─▶ pgvector 余弦召回 top-10 ─▶ 交叉编码器重排 ─▶ 去重 ─▶ 结果
"如何改善睡眠 (词/文件/ embed_query() CosineDistance predict() 按 compiled
-咖啡 dt>…" 日期过滤) order_by distance 文本去重
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
TextToEntries.split_entries_by_max_tokens | 把长条目切成 ≤256 token 的块,给非首块补标题前缀 | src/khoj/processor/content/text_to_entries.py |
EmbeddingsModel | 双编码器:把"一段话"或"一个查询"编成一个向量 | src/khoj/processor/embeddings.py |
TextToEntries.update_embeddings | 哈希去重 + 只为新块编码 + 批量写库 + 删除消失的块 | src/khoj/processor/content/text_to_entries.py |
Entry(Django model) | 一条向量化的文本块,embeddings 是 pgvector VectorField | src/khoj/database/models/__init__.py |
text_search.query | 检索入口:编码查询 → 调数据库召回 | src/khoj/search_type/text_search.py |
EntryAdapters.search_with_embeddings | 真正的向量查询:CosineDistance + 阈值 + order_by | src/khoj/database/adapters/__init__.py |
CrossEncoderModel | 交叉编码器:对召回的一批做精排打分 | src/khoj/processor/embeddings.py |
text_search.rerank_and_sort_results | 组织"重排 + 双分数排序"的逻辑 | src/khoj/search_type/text_search.py |
search_documents(RAG) | 聊天时:推断子查询 → 检索 → 组织成引用喂给 LLM | src/khoj/routers/helpers.py |
主线走一遍(高层,不进代码):
- 入库: 一个
.md文件进来,先按标题层级拆成条目,再把每个条目切成 ≤256 token 的块(split_entries_by_max_tokens),给每块算 MD5 哈希;库里已有相同哈希的块直接跳过,只有新块才送进双编码器gte-small编码成向量,批量写进Entry表。 - 查询: 用户发来
q。先剥掉+词/file:/dt>这些过滤语法,把剩下的"干净查询"送进同一个双编码器编码成查询向量。 - 召回: 在 Postgres 里用
CosineDistance(embeddings, 查询向量)算每个块与查询的"余弦距离",按距离升序取 top-10,同时用刚才剥出的过滤器把范围收窄,还砍掉距离超过阈值(默认0.18)的块。 - 重排: 如果开了重排,把这 10 个块和查询成对喂给交叉编码器
mxbai-rerank-xsmall-v1,得到更准的相关性分,重新排序。 - 去重 & 返回: 按内容去重后返回前
n条。在聊天场景里,这些片段会被拼成"引用上下文"喂给大模型来生成最终回答。
看懂这条主线,就可以按下面的阅读地图下钻细节。
阅读地图(建议顺序)
01-indexing.md— 先看入库:文档怎么被切块(为什么是 256 token、为什么给块补标题前缀)、双编码器怎么编码、以及最巧妙的哈希增量更新(改一个字不会重算整篇)。02-retrieval.md— 再看出库:一次搜索从查询字符串到结果列表的完整链路,重点是 pgvector 余弦召回、置信度阈值、以及"双编码器召回 + 交叉编码器重排"的两段式设计。03-rag-and-boundaries.md— 最后看检索怎么服务聊天:search_documents如何把一句问话拆成多个子查询、检索、去重、组织成引用;顺带讲过滤器语法、用户记忆的向量检索、以及这套设计的边界与局限(如无 ANN 索引 = 精确全扫描),末尾附代码地图。
给 AI agent 的相关性判断: 如果任务是"Khoj 怎么做语义搜索 / 向量检索 / chunking / rerank / RAG",直接进对应章节;如果只是想知道"它对外提供什么检索能力",读完本页 §1、§2 即可。