跳到主要内容

Khoj RAG 接入、过滤器、记忆与边界

本章讲检索怎么服务聊天(RAG),并收尾:过滤器语法、用户记忆的向量检索、巧妙之处、边界与代码地图。

1. RAG:检索怎么接进一次聊天

用户在聊天里问"我该怎么改善睡眠?",Khoj 不是直接把这句丢给 LLM,而是先去用户的私人文档里找证据,再让 LLM 基于证据回答。这段编排在 search_documents(src/khoj/routers/helpers.py:1288)。

它的巧妙之处在于先用 LLM 把问题拆成多个检索子查询,而不是拿原话直接搜:

search_documents 流程

用户问话 q
│ ① 有没有文档?(user_has_entries / agent_has_entries,没有就直接跳过 RAG)

extract_questions(用 LLM 把一句话拆成 ≤5 个检索子查询)
│ 例:"改善睡眠" → ["睡眠质量 改善方法", "冥想 助眠", "咖啡因 睡眠影响"]

对每个子查询调 execute_search(r=True 开重排, dedupe=False)


deduplicated_search_responses(跨子查询按 compiled 文本去重)


compiled_references = [{query, compiled, file, uri}, …] ← 作为"引用上下文"喂给 LLM

关键代码点:

  • 拆子查询: extract_questions(helpers.py:1399)让"快模型"把用户问话 + 聊天历史 + 记忆,推断成一组检索查询(inferred_queries)。这让"一个复杂问题"能从多个角度召回证据。
  • 逐子查询检索: 对每个子查询调 execute_search(..., r=True, dedupe=False)(helpers.py:1370-1379),开启重排、关闭单查询内去重(去重留到最后跨查询做)。
  • 跨查询去重: 合并所有子查询的命中后,deduplicated_search_responsescompiled 内容去重(helpers.py:1385),避免不同子查询捞到同一段。
  • 组织成引用: 去重结果被压成 {query, compiled, file, uri} 列表(helpers.py:1386-1394),这就是喂给大模型的"参考资料",也是回答里"引用来源"的数据。

还有个权限细节:如果用户没在对话里显式带 Notes 命令,检索会被限制到"仅 agent 的知识库"(should_limit_to_agent_knowledge,helpers.py:1321),即把 user 传成 None 只按 agent 过滤。

2. 过滤器:让语义搜索也能"精确制导"

纯语义搜索有时太"软"——你明确知道"只看 2024 年的、只看 .md、必须含某个词",这时需要硬过滤。Khoj 支持在查询串里内联三种过滤语法,在 SQL 层(而非向量层)生效。

过滤器语法示例作用实现文件
词过滤+"必须词" / -"排除词"命中/排除含该词的块src/khoj/search_filter/word_filter.py
文件过滤file:"*.md" / -file:"*.pdf"按文件路径 glob 包含/排除src/khoj/search_filter/file_filter.py
日期过滤dt>"2024-01-01" dt<="2024-12-31"按块内抽取的日期范围过滤src/khoj/search_filter/date_filter.py

三者在 EntryAdapters.apply_filters(adapters/__init__.py:2084)被翻译成 Django Q 条件:

# 真实源码风 —— adapters/__init__.py:2104-2123(节选)
for term in word_filters:
if term.startswith("+"): q_filter_terms &= Q(raw__icontains=term[1:]) # 必须含
elif term.startswith("-"): q_filter_terms &= ~Q(raw__icontains=term[1:]) # 不能含
# 文件过滤:把 glob 的 * ? 转成正则,再 file_path__regex 匹配
regex_term = re.escape(term).replace(r"\*", ".*").replace(r"\?", ".")

日期过滤更特别:它不是过滤"文件修改时间",而是过滤从块正文里抽出来的日期(入库时存进 EntryDates 表,见 01-indexing.md §6)。apply_filtersdt> 翻成对 embeddings_dates__date 的范围条件(adapters/__init__.py:2127-2136)。日期语法本身由正则 dt([:><=]{1,2})["'…](.*?)["'…] 解析(date_filter.py:24)。

为什么要先 defilter 再编码(呼应 02-retrieval.md §6):这些过滤词是"结构化指令",不是"语义内容"。若把 dt>"2024-01-01" 也编进查询向量,会污染语义。所以 execute_search 先剥过滤器、只对干净查询编码,过滤器单独走 SQL(helpers.py:1523-1531)。

3. 用户记忆:同一套检索的另一个消费者

Khoj 的"长期记忆"(从对话里沉淀的事实)也用完全相同的 pgvector 余弦检索,只是换了张表。见 UserMemoryAdapters.search_memories(adapters/__init__.py:2326):

# 真实源码风 —— adapters/__init__.py:2332-2345(节选)
max_distance = model.bi_encoder_confidence_threshold or math.inf
embedded_query = embeddings_model[model.name].embed_query(query)
relevant_memories = (UserMemory.objects.filter(user=user)
.annotate(distance=CosineDistance("embeddings", embedded_query))
.order_by("distance").filter(distance__lte=max_distance))

Entry 检索一模一样的三段式:编码查询 → CosineDistance 标注 → 按距离排序 + 阈值剪枝。UserMemory 也有自己的 embeddings 向量列(models/__init__.py:862)。这说明 Khoj 把"语义检索"抽象成了一个可复用的原语——文档、记忆都是它的消费者。存记忆时同样即时编码(save_memory,adapters/__init__.py:2305-2311)。

4. 巧妙之处(可借鉴的技术)

  • 内容哈希做增量索引。 改一个字只重算受影响的块,不重算整篇——把重索引成本从 O(全库) 降到 O(改动块)(text_to_entries.py:167-183)。
  • 给非首块补标题前缀。 用标题末尾 100 字符,让每个切块都自带"我属于哪节"的上下文,显著减少切块导致的语义漂移(text_to_entries.py:112-117)。
  • 两段式召回-重排 + 分数坐标统一。 便宜的双编码器粗筛 top-10,昂贵的交叉编码器精排;并用 1 - cross_score 把"越大越好的相关分"翻成"越小越好的距离",和双编码器距离用同一套排序(text_search.py:246,251-257)。
  • 过滤器与语义分离。 结构化过滤(词/文件/日期)在 SQL 层做,先从查询里剥离再编码,避免污染语义向量(helpers.py:1523-1531)。
  • RAG 前先用 LLM 拆子查询。 一句复杂问话拆成多角度检索,再跨查询去重,召回覆盖面更广(helpers.py:1346-1385)。
  • 重排失败优雅降级。 远程交叉编码器 HTTP 出错时把分置 0,退化为纯双编码器结果而非整体报错(text_search.py:240-242)。

5. 边界与局限(诚实)

  • 无 ANN 索引 = 精确全扫描。 embeddings 列上没有 HNSW/IVFFlat 索引(全仓无相关迁移),每次查询精确计算该用户全部块的余弦距离。个人/小团队规模没问题,但不是为百万级向量的亚线性检索设计的。(inferred)
  • top_k=10 写死。 底层召回固定取 10 个候选再精排(text_search.py:125),要检索更大候选池得改代码。
  • 默认阈值 0.18 偏严。 余弦距离 > 0.18 的块被判"不相关"直接丢(models/__init__.py:582);模糊/跨领域的查询可能被这个阈值挡掉,需按需调 max_distance
  • 改标题/移动文件触发全量重编码。 因为内容指纹含标题前缀,标题变化会让该文件所有块指纹变化。(inferred)
  • 词过滤是子串匹配。 +"词"raw__icontains(子串),不是全词匹配,可能误命中(adapters/__init__.py:2106)。
  • RAG 拆子查询依赖额外一次 LLM 调用。 extract_questions 是一次真实的模型调用,增加延迟与成本(helpers.py:1346)。

6. 横向对比(同 shelf 的 rag-retrieval 兄弟)

Khoj 在"RAG/检索"这类项目里的取舍偏向**"个人规模、开箱即用、单库(Postgres)统管"**:

  • 向量存储:pgvector 把向量和业务数据放同一个 Postgres 里,省掉独立向量数据库(Milvus/Qdrant/Weaviate)的运维;代价是没上 ANN 索引、走精确扫描,规模上限较低。
  • 重排: 内置轻量交叉编码器 mxbai-rerank-xsmall-v1 做本地重排,而不少 RAG 框架把重排当可选外部服务。
  • 切块: 用通用的 RecursiveCharacterTextSplitter,但叠了"标题前缀"这类领域适配,比裸切块更懂笔记结构。

交叉参考:总库 ai-agent-reference 的 rag-retrieval 原理页(向量检索 / 切块 / 重排的第一性原理与各子库实现对比)。

7. 代码地图(导航索引)

主题文件路径关键符号
检索入口(编码查询 + 召回)src/khoj/search_type/text_search.pyquery
pgvector 余弦召回src/khoj/database/adapters/__init__.pyEntryAdapters.search_with_embeddings
过滤器 → SQL 条件src/khoj/database/adapters/__init__.pyEntryAdapters.apply_filters
结果转换 + 两级去重src/khoj/search_type/text_search.pycollate_results / deduplicated_search_responses
重排编排src/khoj/search_type/text_search.pyrerank_and_sort_results / sort_results
交叉编码器打分src/khoj/search_type/text_search.pycross_encoder_score
双 / 交叉编码器模型src/khoj/processor/embeddings.pyEmbeddingsModel / CrossEncoderModel
编码入口(文档/查询)src/khoj/processor/embeddings.pyembed_documents / embed_query / predict
切块 + 标题前缀src/khoj/processor/content/text_to_entries.pysplit_entries_by_max_tokens
哈希增量更新 + 落库src/khoj/processor/content/text_to_entries.pyupdate_embeddings / hash_func
旧文件式增量(标记新块)src/khoj/processor/content/text_to_entries.pymark_entries_for_update
Markdown → 条目(标题前缀铺垫)src/khoj/processor/content/markdown/markdown_to_entries.pyconvert_markdown_entries_to_maps
端到端搜索编排 + 缓存src/khoj/routers/helpers.pyexecute_search
RAG:拆子查询 + 检索 + 引用src/khoj/routers/helpers.pysearch_documents / extract_questions
HTTP 搜索端点src/khoj/routers/api.pysearch
数据模型(向量列/配置)src/khoj/database/models/__init__.pyEntry / SearchModelConfig / UserMemory
用户记忆向量检索src/khoj/database/adapters/__init__.pyUserMemoryAdapters.search_memories
过滤器语法解析src/khoj/search_filter/WordFilter / FileFilter / DateFilter
启动时加载模型src/khoj/configure.pystate.embeddings_model / state.cross_encoder_model 初始化
pgvector 扩展启用src/khoj/database/migrations/0003_vector_extension.pyVectorExtension