数据截至 (上游 commit ae229ca894c0)
Khoj RAG 接入、过滤器、记忆与边界
本章讲检索怎么服务聊天(RAG),并收尾:过滤器语法、用户记忆的向量检索、巧妙之处、边界与代码地图。
1. RAG:检索怎么接进一次聊天
用户在聊天里问"我该怎么改善睡眠?",Khoj 不是直接把这句丢给 LLM,而是先去用户的私人文档里找证据,再让 LLM 基于证据回答。这段编排在 search_documents(src/khoj/routers/helpers.py:1287)。
它的巧妙之处在于先用 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:1398)让"快模型"把用户问话 + 聊天历史 + 记忆,推断成一组检索查询(inferred_queries)。这让"一个复杂问题"能从多个角度召回证据。 - 逐子查询检索: 对每个子查询调
execute_search(..., r=True, dedupe=False)(helpers.py:1369-1378),开启重排、关闭单查询内去重(去重留到最后跨查询做)。 - 跨查询去重: 合并所有子查询的命中后,
deduplicated_search_responses按compiled内容去重(helpers.py:1384),避免不同子查询捞到同一段。 - 组织成引用: 去重结果被压成
{query, compiled, file, uri}列表(helpers.py:1385-1393),这就是喂给大模型的"参考资料",也是回答里"引用来源"的数据。
还有个权限细节:如果用户没在对话里显式带 Notes 命令,检索会被限制到"仅 agent 的知识库"(should_limit_to_agent_knowledge,helpers.py:1319),即把 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:2090)被翻译成 Django Q 条件:
# 真实源码风 —— adapters/__init__.py:2110-2129(节选)
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_filters 把 dt> 翻成对 embeddings_dates__date 的范围条件(adapters/__init__.py:2133-2142)。日期语法本身由正则 dt([:><=]{1,2})["'…](.*?)["'…] 解析(date_filter.py:24)。
为什么要先 defilter 再编码(呼应 02-retrieval.md §6):这些过滤词是"结构化指令",不是"语义内容"。若把 dt>"2024-01-01" 也编进查询向量,会污染语义。所以 execute_search 先剥过滤器、只对干净查询编码,过滤器单独走 SQL(helpers.py:1522-1530)。
3. 用户记忆:同一套检索的另一个消费者
Khoj 的"长期记忆"(从对话里沉淀的事实)也用完全相同的 pgvector 余弦检索,只是换了张表。见 UserMemoryAdapters.search_memories(adapters/__init__.py:2332):
# 真实源码风 —— adapters/__init__.py:2338-2351(节选)
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:2311-2317)。