跳到主要内容

Khoj — 检索与 RAG 子系统架构与原理

30 秒导读: Khoj 是一个"个人第二大脑"——你把笔记(Markdown / PDF / Org / Notion…)交给它,它帮你用自然语言问,从你自己的资料里找答案。本文只讲它最核心的一块:检索(retrieval)与 RAG——文档如何被切块、向量化、存进数据库,查询时又如何被召回、重排,最后作为"引用"喂给大模型来回答问题。

本套文档聚焦 Khoj 仓库里 search_type/processor/embeddings.pyprocessor/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 VectorFieldsrc/khoj/database/models/__init__.py
text_search.query检索入口:编码查询 → 调数据库召回src/khoj/search_type/text_search.py
EntryAdapters.search_with_embeddings真正的向量查询:CosineDistance + 阈值 + order_bysrc/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)聊天时:推断子查询 → 检索 → 组织成引用喂给 LLMsrc/khoj/routers/helpers.py

主线走一遍(高层,不进代码):

  1. 入库: 一个 .md 文件进来,先按标题层级拆成条目,再把每个条目切成 ≤256 token 的块(split_entries_by_max_tokens),给每块算 MD5 哈希;库里已有相同哈希的块直接跳过,只有新块才送进双编码器 gte-small 编码成向量,批量写进 Entry 表。
  2. 查询: 用户发来 q。先剥掉 +词/file:/dt> 这些过滤语法,把剩下的"干净查询"送进同一个双编码器编码成查询向量。
  3. 召回: 在 Postgres 里用 CosineDistance(embeddings, 查询向量) 算每个块与查询的"余弦距离",按距离升序取 top-10,同时用刚才剥出的过滤器把范围收窄,还砍掉距离超过阈值(默认 0.18)的块。
  4. 重排: 如果开了重排,把这 10 个块和查询成对喂给交叉编码器 mxbai-rerank-xsmall-v1,得到更准的相关性分,重新排序。
  5. 去重 & 返回: 按内容去重后返回前 n 条。在聊天场景里,这些片段会被拼成"引用上下文"喂给大模型来生成最终回答。

看懂这条主线,就可以按下面的阅读地图下钻细节。


阅读地图(建议顺序)

  1. 01-indexing.md — 先看入库:文档怎么被切块(为什么是 256 token、为什么给块补标题前缀)、双编码器怎么编码、以及最巧妙的哈希增量更新(改一个字不会重算整篇)。
  2. 02-retrieval.md — 再看出库:一次搜索从查询字符串到结果列表的完整链路,重点是 pgvector 余弦召回、置信度阈值、以及"双编码器召回 + 交叉编码器重排"的两段式设计。
  3. 03-rag-and-boundaries.md — 最后看检索怎么服务聊天:search_documents 如何把一句问话拆成多个子查询、检索、去重、组织成引用;顺带讲过滤器语法、用户记忆的向量检索、以及这套设计的边界与局限(如无 ANN 索引 = 精确全扫描),末尾附代码地图

给 AI agent 的相关性判断: 如果任务是"Khoj 怎么做语义搜索 / 向量检索 / chunking / rerank / RAG",直接进对应章节;如果只是想知道"它对外提供什么检索能力",读完本页 §1、§2 即可。