数据截至 (上游 commit 5053c08115bd)
检索栈:一句提问如何变成带引用的答案
30 秒导读: 用户问一句话,模型只决定"搜什么"(一个
queries数组);剩下的——扩展成多条加权查询、并行打向量库、按权限过滤、融合排序、让小 LLM 裁剪、渲染成带编号的 JSON、再在流式输出里把[1]换成可点链接——全是 Onyx 自己在代码里做的。这一章从工具入口一路下探到向量库,再折回来讲引用怎么渲染。
本章属于 Onyx 系列。上游是一次对话的生命周期(谁在什么时候调这个工具)和工具与子 agent(工具怎么被定义和并行执行);下游是索引栈(这些 chunk 是怎么进库的)。
1. 这一章讲什么(零基础也能懂)
一句话定义: Onyx 的"检索栈"是把一句自然语言提问,变成一组带编号、可点击引用的文档片段,塞回给大模型当证据。
它要解决的问题: 大模型不知道你公司内网写了什么。RAG(检索增强生成,先搜再答)的常规做法是"把问题丢进向量库、取 top-k、拼进 prompt"。Onyx 认为这样不够,它在这条路上加了六道额外工序:
| 工序 | 干嘛的 | 为什么需要 |
|---|---|---|
| 查询扩展 | 一句问题变成语义 + 关键词多条查询 | 短查询、生僻术语,单条向量查询召回差 |
| 范围判定 | 判断该只搜 Slack 还是搜全部 | 用户说"在 Jira 里找"时不该全库扫 |
| 权限过滤 | ACL 串下推到向量库的 filter | 搜索结果不能泄漏你无权看的文档 |
| 加权 RRF 融合 | 多条查询的排名合成一个排名 | 不同查询的分数不可直接比较 |
| 两次 LLM 裁剪 | 先选文档、再决定读多深 | 降噪、省 token、减少幻觉 |
| 引用回写 | 流里把 [1] 换成 [[1]](url) | 用户要能点开原文核对 |
一句话直觉: 把它想成一个图书管理员——你只说了个模糊题目,他自己改写成几个检索式、只翻你有权限进的书架、把几份书单合并去重、快速翻一遍挑出真正该细读的几本、最后在答案里贴上便利贴写清"这句话出自第 3 本第 40 页"。
模型看到的工具长什么样: 极简,只有一个数组参数(search_tool.py:564-587,SearchTool.tool_definition)。注意描述里明确告诉模型别自己写时间/来源限定——那是下游的活:
List of search queries to execute, typically a single query. Query expansion and
filter extraction steps will be run automatically downstream, do not include time
or source type scoping details in your query.
2. 顶层全景:七道关卡
怎么读这张图:从上到下是一次 internal_search 调用的时间顺序,左侧是关卡编号,命中即往下走。
用户提问 ──► LLM 只输出 {"queries": ["..."]}
│
① 查询扩展 + 范围判定 │ 两个小 LLM 调用,并行跑
语义改写 / 关键词扩展 / 决定搜哪些 source
▼
② 权重分组 每条查询配一个权重和 hybrid_alpha
▼
③ 并行检索 N 条查询 + Slack 联邦,各自打向量库
▼
④ 加权 RRF 融合 N 份排名 → 一份排名 → 相邻 chunk 合成 section
▼
⑤ 两次 LLM 裁剪 先选文档,再决定每篇读多深
▼
⑥ 渲染成 JSON 每个文档分到一个引用号
▼
⑦ 流式引用回写 [1] ──► [[1]](https://…) + CitationInfo 包
部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
SearchTool | 工具入口,编排①~⑥ | backend/onyx/tools/tool_implementations/search/search_tool.py |
search_pipeline | 单条查询的检索管线:建 filter → 检索 → EE 删减 | backend/onyx/context/search/pipeline.py |
search_chunks | 联邦源和向量库的并行分叉点 | backend/onyx/context/search/retrieval/search_runner.py |
DocumentIndex | 向量库抽象(Vespa / OpenSearch 两套实现) | backend/onyx/document_index/interfaces_new.py |
DynamicCitationProcessor | 流式 token 里识别并改写引用标记 | backend/onyx/chat/citation_processor.py |
一句话走一遍: SearchTool.run 先在一个短命 DB session 里把所有要用的东西(ACL、embedding 模型、联邦函数、Slack token)预取完(search_tool.py:680-762),然后关掉 session——后面所有并行 worker 只操作纯 Python 对象,一个数据库连接都不占。这是整个模块最重要的一条工程约束。
3. 搜索工具层:把一句话变成一组加权查询
3.1 扩展与范围判定 :两件事一起并行
要解决的小问题: 模型给的查询往往太短、太口语。而且"搜哪些数据源"这个决定既不能每次重算(贵),也不能一次定死(对话中途可能改)。
_expand_queries_and_decide_scope(search_tool.py:602-660)把这两件事打包成一个 threadpool 批次:
semantic_query_rephrase—— 结合对话历史,改写成一条自洽的语义查询(secondary_llm_flows/query_expansion.py:68)。keyword_query_expansion—— 生成最多 3 条纯关键词查询(query_expansion.py:150)。decide_search_scope—— 从对话里判断该限定到哪些DocumentSource(secondary_llm_flows/source_filter.py:55)。
两个省钱开关藏在这个函数里,值得单独看:
- 扩展结果缓存。
self._cached_expansion存住上次的扩展;同一轮对话里重复调用internal_search时,扩展是 source-agnostic 的,可以直接复用(search_tool.py:834-839)。 - 范围判定的单向闩锁。
self._scope_decision_settled = plan_scope is None(search_tool.py:649)——一旦某次判定发现"对话里没有来源指令",本轮后续调用就再也不跑这个 LLM 了。注释给的理由很干脆:对话中途不可能凭空冒出一个来源指令。
decide_search_scope 还有一个廉价短路:连接的 source 少于两个时直接返回 None(source_filter.py:74-75),没什么可选的就别烧 token。
3.2 权重表:四类查询,四个权重
扩展完的查询不是平等的。权重写死在 search/constants.py,注释里明说"不给用户调,用户调不好":
| 查询来源 | 权重常量 | 值 | hybrid_alpha |
|---|---|---|---|
| 专门改写的语义查询 | LLM_SEMANTIC_QUERY_WEIGHT | 1.3 | 默认(0.5) |
| 关键词扩展查询 | LLM_KEYWORD_QUERY_WEIGHT | 1.0 | 0.2 |
| 模型自己给的原始查询 | LLM_NON_CUSTOM_QUERY_WEIGHT | 0.7 | 默认 |
| 用户原话 | ORIGINAL_QUERY_WEIGHT | 0.5 | 默认 |
hybrid_alpha 是向量分和关键词分的配比,越小越偏关键词。关键词查询用 0.2(KEYWORD_QUERY_HYBRID_ALPHA),刚好会在下游被翻译成 QueryType.KEYWORD 走另一套打分(见 §4.3)。
去重发生在权重分组之后。deduplicate_queries(search_tool.py:170-191)按小写比较去重,但权重相加——同一条查询被两个来源生成,说明它更可信,分数该更高:
# 示意,非源码
query_map = {}
for query, weight in queries_with_weights:
key = query.lower()
if key in query_map: # 撞了就把权重 加起来
old_q, old_w = query_map[key]
query_map[key] = (old_q, old_w + weight)
else:
query_map[key] = (query, weight) # 保留第一次出现的大小写
重点看:撞车不是丢弃,是加权。
3.3 一个容易忽略的不对称:历史里记的查询和 UI 里显示的不一样
三个地方各记一份查询,内容并不相同:
| 落点 | 记的是什么 | 依据 |
|---|---|---|
| 前端实时流 | 扩展后的全部查询,按权重降序 | SearchToolQueriesDelta(queries=all_queries),search_tool.py:963-970 |
| 给 LLM 的工具结果 | 扩展后的查询串在 scope note 里 | _build_scope_note,search_tool.py:155-167 + search_tool.py:877-884 |
| 落库的工具调用参数 | 模型的原始 queries | tool_call_arguments=tool_call.tool_args,llm_loop.py:1297 |
后果很实际:会话重新加载时,UI 是从落库的原始参数重建的(server/query_and_chat/session_loading.py:595-596),所以刷新页面后看到的查询列表会 比实时流少几条。
scope note 本身也是一个巧妙的小设计——当搜索被限定到某些来源时,工具结果末尾附一句"本次只搜了 X,跑的查询是 Y,换个词再调一次可以继续搜",等于用自然语言把状态回灌给模型:
(This internal search covered only: {searched}. Queries run: {queries_str}.
Call internal_search again with different query terms to keep searching.)
3.4 融合:加权 RRF + 相邻 chunk 合成 section
N 条查询各返回一份排名。这些排名的分数不可比——每个后端都做过自己的归一化。所以 Onyx 不比分数,比名次。
weighted_reciprocal_rank_fusion(search/search_utils.py:28-...)的公式是:
RRF_score(item) = Σ over all rankers of: weight / (k + rank(item))
k = RRF_K_VALUE = 50(constants.py)。k 越大,头部名次的优势越平,越强调"在多个排名里都出现过"这件事。融合的 ID 是 f"{chunk.document_id}_{chunk.chunk_id}"(search_tool.py:1044)——粒度是 chunk 不是文档。
融合完立刻做一次相邻合并:merge_individual_chunks(pipeline.py:145-259)把同一文档里 chunk_id 只差 1 的 chunk 拼成一个 InferenceSection,让 LLM 看到连续上下文而不是碎片。这个函数有个不显眼但正确的细节——合成 section 的 center_chunk 取的是在原始列表里排名最靠前的那个 chunk(pipeline.py:195-200),所以 section 继承了组内最好的排名位置。
3.5 两次 LLM 裁剪:先选文档,再决定读多深
融合后的 section 还是太多。Onyx 在这里连开两个小 LLM 决策,中间夹一次 token 预算裁剪:
top_sections
│
├─ ① _trim_sections_by_tokens 用真 tokenizer 逐条累加,超预算就截断
│ 预算 = max_llm_chunks × DOC_EMBEDDING_CONTEXT_SIZE
│ 每篇最多算 MAX_CHUNKS_FOR_RELEVANCE(=3) 个 chunk
▼
├─ ② select_sections_for_expansion LLM 一次看全部,挑出值得读的
▼
└─ ③ expand_section_with_context 每篇并行,LLM 决定读整篇还是读上下几块
└─ merge_overlapping_sections 展开后可能重叠,再合一次
三处的用意各不相同,别混为一谈:
- ①(
search_tool.py:225-266)防的是"短 chunk 洪水"。有的文档切得极碎,几十个小 chunk 能把 LLM 上下文冲垮。所以按 token 而不是按条数裁,且每篇最多只计 3 个 chunk(MAX_CHUNKS_FOR_RELEVANCE)——常量注释写得很清楚:一篇文档如果确实相关,用最高分那一段周围的内容就足以判断,不该让它挤掉别的文档。 - **②(
secondary_llm_flows/document_filter.py:189)**让 LLM 一次看到跨文档的全景再挑,而不是逐篇判 断。 - **③(
search/search_utils.py:356)**才逐篇并行展开,每篇只给 LLM 一个简单问题——"这段够不够,要不要读全文"。工具头部的模块注释把理由说透了:Keeping every LLM decision step as simple as possible is key for reliable performance.
③ 外面套了一层 expand_section_safe(search_tool.py:1138-1161)——任何异常都吞掉并退回原 section。展开失败不该让整次搜索失败。
3.6 结果渲染:引用号在这里诞生
convert_inference_sections_to_llm_string(backend/onyx/tools/tool_implementations/utils.py:29-125)是"文档变引用号"的地方。它两遍扫描:
- 第一遍按
document_id分配引用号,同一文档的多个 section 共用一个号(utils.py:51-57)。 - 第二遍构造 JSON,每条带
document(就是引用号)、title、content等字段。
返回值是 (json_str, citation_mapping)——JSON 给 LLM,citation_mapping: dict[int, str](号 → document_id)后面喂给引用处理器。这就是引用编号的唯一来源。
一个附带的小设计:如果 chunk 关联了真实文件(chunk.file_id 非空),content 会被换成一句引导语,告诉模型"完整文件在沙箱里叫 X,想细读请用 Python 解释器"(utils.py:21-26, 102-110),只留一段摘录。大文件不进 prompt,改走代码解释器。
4. 检索管线:从一条查询到一批 chunk
4.1 search_pipeline 只做三件事
pipeline.py:263-348,结构极干净:
search_pipeline
├─ _build_index_filters(...) → IndexFilters(权限 + 范围 + 时间 + 标签)
├─ strip_stopwords(query) → query_keywords(给 BM25 用)
├─ search_chunks(...) → list[InferenceChunk]
└─ post_query_censoring → EE 字段级二次删减(社区版是 no-op)
strip_stopwords(pipeline.py:317)单独产出 query_keywords,因为 BM25 的标题打分对停用词特别敏感——schema 注释里专门提过这点(见 §6.3)。
4.2 _build_index_filters:过滤器在这里定型
pipeline.py:40-142。它把五个来源的约束揉成一个 IndexFilters:
| 来源 | 字段 | 备注 |
|---|---|---|
| 用户/前端选择 | source_type、tags、time_cutoff、document_set | 会被复核权限 |
| Persona 配置 | persona_document_sets、persona_time_cutoff | 用户没指定时兜底 |
| 项目/助手 | project_id_filter、persona_id_filter、attached_document_ids、hierarchy_node_ids | 见 §6.4 的"知识范围"语义 |
| 用户身份 | access_control_list | ACL 串,见 §5 |
| 租户 | tenant_id | 仅多租户模式 |
这里补了一个真实的越权洞。 当调用方自带 document_set 名单时,函数会重新查一遍这个用户是否真有权访问这些 document set,不通过就抛 INSUFFICIENT_PERMISSIONS(pipeline.py:66-84)。代码注释直白写明动机:This closes the API-layer bypass where a user could override the persona's configured document sets with arbitrary names. 同样的校验在 SearchTool.run 的预取段又做了 一次(search_tool.py:688-709)——纵深防御。
4.3 search_chunks:并行分叉点
retrieval/search_runner.py:89-163。它把"联邦源"和"本地向量库"摊平成一个并行批次:
┌─ 每个联邦源一个 retrieval_function ──┐
search_chunks┤ ├─► run_functions_tuples_in_parallel
└─ 本地检索(二选一)─────────────────┘
├─ hybrid_alpha == 0.0 → _keyword_search (纯关键词,不算 embedding)
└─ 否则 → _embed_and_hybrid_search
三个细节值得记:
- 纯关键词分支能省掉一次 embedding 调用,但注释警告只有 OpenSearch 侧的生产者会设
hybrid_alpha=0.0,Vespa 走这条会抛NotImplementedError(search_runner.py:134-144,对照vespa_document_index.py:993-999)。 hybrid_alpha在这里变成枚举:query_type = QueryType.KEYWORD if hybrid_alpha <= 0.2 else QueryType.SEMANTIC(search_runner.py:65)。所以工具层设的 0.2 一路传下来,最终选中的是 Vespa 的hybrid_search_keyword_base_*排序 profile。- 来源全是联邦时不跑本地检索:
normal_search_enabled检查 source 过滤集合减去联邦源后是否还有剩(search_runner.py:129-131)。
融合用 combine_retrieval_results(search_runner.py:27-48):按 (document_id, chunk_id) 去重,同 key 保留分数高的那份,再按分数排序。注意这是管线内部的融合(同一条查询的多个源),跟 §3.4 工具层跨查询的 RRF 不是一回事。
inference_sections_from_ids(search_runner.py:167)顶着一行 # TODO: This is unused code. —— 目前是死代码,别照着它理解主流程。
5. 权限即过滤器
核心思路:权限不是搜完再筛,而是编译成一串字符串下推到向量库的 filter 里。
社区版的 ACL 串短得离谱(access/access.py:114-127,_get_acl_for_user):
if user.is_anonymous:
return {PUBLIC_DOC_PAT}
return {prefix_user_email(user.email), PUBLIC_DOC_PAT}
匿名用户只能看公开文档;登录用户是"我自己 + 公开"。企业版通过 fetch_versioned_implementation(access.py:130-134)换成含用户组、外部组的更长版本。文档侧对应 DocumentAccess.to_acl()——检索时的判定是两个集合有交集即放行。
build_access_filters_for_user(context/search/preprocessing/access_filters.py:8-10)只是把 set 转成 list,然后一路进 IndexFilters.access_control_list。这条串在 Vespa 侧会变成 weightedSet() 而不是 OR 链——注释解释了原因:单个用户可能有上万条 ACL 项,OR 链在这个规模会让 Vespa 直接返回 HTTP 400(vespa/shared_utils/vespa_request_builders.py:46-60)。
5.1 过滤器管不住的:字段级删减
有些连接器(典型是 Salesforce)里,"你能看到这个对象"不等于"你能看到它的所有字段"。这种粒度没法用 ACL 串表达,于是加了一道检索后的删减,挂在企业版:
# pipeline.py:303-310
censored_chunks = fetch_ee_implementation_or_noop(
"onyx.external_permissions.post_query_censoring",
"_post_query_chunk_censoring",
retrieved_chunks, # ← 社区版直接返回这个值
)(chunks=retrieved_chunks, user=user)
这是 Onyx 全库的 EE 挂载惯用法(utils/variable_functionality.py:167-197):第三个参数是社区版的 no-op 返回值,非 EE 环境下整个调用退化成"原样返回"。
EE 实现(ee/onyx/external_permissions/post_query_censoring.py:35-)本身有三个可借鉴之处:
| 做法 | 依据 | 为什么 |
|---|---|---|
| 匿名用户直接丢掉全部需删减来源的 chunk | post_query_censoring.py:49-50 | 最保守,不试图部分放行 |
| 删减函数抛异常 → 该来源整批丢弃并继续 | 同文件 except 分支 | 失败方向偏保守,不因一个源挂掉整次搜索 |
| 最后按原始顺序重建列表 | 文件末尾 IMPORTANT 注释 | 删减不能打乱前面辛苦排好的名次 |
还有一条粗粒度的取舍写在注释里:只要某个 source 存在任何一个 sync 型 cc_pair,该 source 的所有 chunk 都会走删减——为的是不用对每个 chunk 反查它的 cc_pair。
6. 索引抽象与两套后端
6.1 能力拆成 mixin
document_index/interfaces_new.py 没有定义一个胖接口,而是把能力切成七个抽象基类,DocumentIndex 只是它们的组合(interfaces_new.py:478-487):
| Mixin | 关键方法 | 行 |
|---|---|---|
SchemaVerifiable | verify_and_create_index_if_necessary | 175 |
Indexable | index | 205 |
Deletable | delete | 244 |
Updatable | update | 280 |
IdRetrievalCapable | id_based_retrieval | 312 |
HybridCapable | hybrid_retrieval / keyword_retrieval / semantic_retrieval | 348 |
RandomCapable | random_retrieval | 432 |
拆开的价值是类型层面的按需依赖——检索路径只需要 HybridCapable,索引路径只需要 Indexable,而不用整个接口。文件顶部还有一条术语澄清,值得先读:Onyx 说的 "Document" 是整篇文档,但索引里存的对象其实是 chunk(interfaces_new.py:17-21)。
还有一个安全默认值藏在 IndexRetrievalFilters(interfaces_new.py:170-187):access_control_list 默认是 frozenset({PUBLIC_DOC_PAT}) 而不是空集——忘记传就只能搜到公开文档,而不是搜到全部。(注释同时说明这个类目前还没接上。)
6.2 选型:谁在跑
document_index/factory.py 做两件不同的事,别搞混:
get_default_document_index(factory.py:96-119)—— 检索用哪个后端。按 DB 里的get_opensearch_retrieval_state开关二选一。get_all_document_indices(factory.py:122-151)—— 写入要写哪几个后端。迁移期可能同时写 Vespa 和 OpenSearch,注释要求 Vespa 排第一,因为冲突时假定 Vespa 状态更新(factory.py:128-132)。
两边返回的都不是单个索引,而是 VespaIndexPair / OpenSearchIndexPair —— 主索引 + 副索引(换 embedding 模型时的重建目标)。所有检索方法都直接委托给 _primary(vespa_document_index.py:1315-1331、opensearch_document_index.py:1104-1120)。
6.3 Vespa:排序逻辑全下推
Vespa 侧把打分写进 schema 的 rank-profile(document_index/vespa/app_config/schemas/danswer_chunk.sd.jinja):
| 函数 | 作用 | 行 |
|---|---|---|
document_boost | 用户反馈投票 → 0.5×~2× 分数,分段 sigmoid 拉宽 3 倍 | 205 |
document_age | 距上次更新的年数;无更新时间按 ~91.3 天算 | 211 |
recency_bias | max(1 / (1 + decay_factor * age), 0.75),衰减地板 0.75 | 222 |
aggregated_chunk_boost | 信息量分类器给的聚合加权 | 216 |
hybrid_search_semantic_base_{dim}(第 229 行起)分两相:first-phase 只用向量(第 245 行),这样完全没有关键词命中的文档也有机会进入候选;global-phase 才做 alpha 加权 + 上述三个乘法 boost(第 249-281 行),rerank-count: 1000。
hybrid_retrieval(vespa_document_index.py:903-959)在应用侧只管拼 YQL 和参数:
target_hits = min(max(4 * num_to_retrieve, 100), RERANK_COUNT)—— 候选集有上下界,避免大索引上过量取回(vespa_document_index.py:915)。- YQL 是四个子句的
or:内容向量 NN、标 题向量 NN、weakAnd关键词、content_summary关键词(:906-913)。 alpha不再由调用方传,而是从query_type反推(:930-934)——注释诚实记下这是为了对齐旧接口的既有行为。
6.4 OpenSearch:两个不得不接受的取舍
OpenSearch 侧的取舍全写在 document_index/opensearch/README.md 里,这是全库最值得读的一份设计笔记。
取舍一:混合查询里"缺失分数 = 0",等价于最小值裁剪。
OpenSearch 的 hybrid 查询是几条完全独立的子查询,各跑各的 Search 阶段,最后由 normalization 处理器合并。问题是:只被关键词命中、没被向量命中的文档,它的向量分不会补算,直接记 0。
README 第 20-26 行解释了为什么这比"真实分数"更糟:如果真去算,那个分数必然低于列表里所有已有分数(否则它早就出现了),而它一旦参与归一化,就会把其他分数整体推高,让自己成为一个被区分开的最低分。记 0 则相当于把它硬钉在最低值上——所以 README 把这个现象命名为 "minimum value clipping"(最小值裁剪)。
取舍二:时间衰减和 boost 不能下推,必须回到 Onyx 代码里做。
理由分两层:
- 归一化之前不能 boost。 embedding 分数不是 0
1 均匀分布,通常密集聚在 0.60.8,而且随模型和查询漂移。README 第 29-33 行举了个例子:给一个 0.6~0.8 区间的分数打 50% 折扣,它不会落到第 50 百分位,而是直接掉到 0.6 以下变成最差匹配。加法 boost 同理。 - 归一化之后没机会 boost。 OpenSearch 的 normalization 处理器跑在最后,只作用于各独立子查询的结果。想加一条"最近更新"的时间子查询?它引不进任何新文档(新文档的关键词/向量分是 0,会被压到最低),只能把已有的低分文档往上抬一点。而且这个字段既不能排序(只能过滤)、也不好归一化,还会逼你从 z-score 退回 min-max——README 第 44-47 行明确说 z-score 更好:对离群点不敏感、能应对分布漂移、跨分布可比。
结论:OpenSearch 只负责"初筛",时间衰减和 boost 由 Onyx 代码在拿到结果后补。 README 末尾补了一句约束——这些精修的影响不该大到"需要多取几个数量级的结果回来"(第 52-54 行)。
代码侧的对应物是归一化权重(opensearch/search.py:62-107):
| 子查询 | 权重 |
|---|---|
| 标题向量 | 0.1 |
| 内容向量 | 0.45 |
| 标题+内容合并关键词 | 0.45 |
标题向量只有 0.1,注释给的理由是标题本来就被包含在内容里,所以它只该当 boost,不该当独立打分项(search.py:73-75)。README 末尾对 BM25 标题分说了同样的话。函数结尾还有一句 assert sum(...) == 1.0——权重顺序必须和子查询顺序一一对应,写反了就是静默错分。
get_hybrid_search_query(search.py:315-421)里两个参数值得单看:pagination_depth 保证各子查询在聚合前贡献同量候选;filter 放在 hybrid 层级,独立应用到每条子查询,避免某条子查询的结果在聚合阶段被大量丢弃(search.py:388-396)。另外,hybrid 查询下 profiling 是坏的,别加(search.py:343-344,附上游 issue 链接)。
6.5 过滤语义:知识范围的"OR 内、AND 外"
document_index/FILTER_SEMANTICS.md 是这套过滤器的规范文档,描述的是 OpenSearch(现行后端),并标注 Vespa 在一处不同。
五类过滤器的组合规则:
| 类别 | 字段 | 组合方式 |
|---|---|---|
| 可见性 | hidden | 恒生效(除非 include_hidden) |
| 租户 | tenant_id | AND(仅多租户) |
| ACL | access_control_list | 组内 OR,与其他 AND |
| 收窄 | source_type、tags、time_cutoff | 各自组内 OR,与其他 AND |
| 知识范围 | document_set、attached_document_ids、hierarchy_node_ids、persona_id_filter、project_id_filter | 组内 OR,与其他 AND |
关键规则是"知识范围"这一组:五个字段里任何一个非空就会开启这组过滤(opensearch/search.py:1307-1314),组内用 {"bool": {"should": [...], "minimum_should_match": 1}} 拼成一个孤立子句——注释特意说明这样能让它在 OpenSearch 里独立于其他子句被缓存(search.py:1244-1247)。五个都为空时完全不加这组过滤,助手能看到全部(受 ACL 约束)。
两个字段是"主触发器"(primary trigger),可以单独开启范围:
persona_id_filter—— 但它不是当前 persona 的原始 ID,只有当 persona 的用户文件塞不进 LLM 上下文、需要改走向量库时才会被设上(FILTER_SEMANTICS.md:84-87)。project_id_filter—— 项目内的对话被限定在该项目,不搜团队知识。这是 Vespa 和 OpenSearch 唯一的语义差异:Vespa 侧它仍是"加性"的,只会扩大已有范围(FILTER_SEMANTICS.md:89-91)。
工具层配合这条语义做了个显式短路:项目模式下,user_selected_filters 整个被置 None(search_tool.py:508-512),因为"项目就是搜索范围,没有别的限制"。
7. 外部与联邦检索
Onyx 的"检索"不止本地向量库,还有两类外部来源,接入方式完全不同。
┌──────────────────────────────────────┐
用户提问 ──────►│ ③ 并行批次(同一个 threadpool) │
├──────────────────────────────────────┤
│ 查询1 → 向量库 │
│ 查询2 → 向量库 │
│ ... │
│ 原始问题 → Slack 联邦(只跑一次) │
└──────────────────────────────────────┘
7.1 Slack 联邦:为什么只跑一次
Slack 搜索被塞进和向量库查询同一个并行批次,但只用原始问题跑一次(search_tool.py:1014-1032)。注释点明了动机:This avoids the query multiplication problem where each Vespa query would trigger a separate Slack search. ——N 条扩展查询乘上一次 Slack API 调用,会把外部 API 打爆。它的权重取 ORIGINAL_QUERY_WEIGHT,直接参与 §3.4 的 RRF。
其他细节:
- Token 全部预取。
_prefetch_slack_data(search_tool.py:322-425)在关 session 前把 access token / bot token / 频道配置取好,两条路径:Slack bot 上下文走 persona 的 document set 找联邦连接器;Web 用户走 OAuth token。 - 异常一律吞掉返回空列表(
search_tool.py:471-473)——Slack 挂了不该让整次搜索挂。 - 分数要做尺度对齐。
convert_slack_score(federated/slack_search.py:987-993)把 Slack 分数除以 90000 再钳到 [0,1]。函数注释很坦白:这只影响 UI 和 LLM 看到的排序,不影响裁剪,对答案质量影响很小。 - 范围判定会关掉它。 如果
resolved_scope里没有DocumentSource.SLACK,token 被直接置None(search_tool.py:896-898)。
7.2 Web 搜索:六个 provider 一个接口
Web 搜索是另一个工具(WebSearchTool),不走 search_pipeline,但共用同一个结果渲染函数和引用编号机制(web_search/web_search_tool.py:345-355)。
provider 在 web_search/providers.py:70-137 里按枚举分派,客户端全在 web_search/clients/:
| Provider | 特有配置 | 客户端 |
|---|---|---|
| SearXNG | 需要自建实例 URL,不需要 API key | searxng_client.py |
| Exa | — | exa_client.py |
| Brave | 超时、国家、语言、safesearch、freshness | brave_client.py |
| Serper | — | serper_client.py |
| Tavily | search_depth、topic、country | tavily_client.py |
| Google PSE | 额外要 search engine id(cx) | google_pse_client.py |
provider_requires_api_key(providers.py:62-67)单独存在,就是为了给 SearXNG 开口子——它靠公共搜索引擎,本身不需要 key。
抓正文(content provider)是另一条线:Onyx 自带爬虫、Firecrawl、Exa、Tavily Extract 四选一,配不上就退回自带爬虫(providers.py:196-215)。
8. 引用回写:[1] 怎么变成可点链接
8.1 三态模式
DynamicCitationProcessor(chat/citation_processor.py:69-)按 CitationMode(:27-47)分三种行为:
| 模式 | 输出 | 发 CitationInfo 吗 | 用在哪 |
|---|---|---|---|
HYPERLINK | [1] → [[1]](url) | 发 | 给用户看的最终答案 |
KEEP_MARKERS | [1] 原样保留 | 不发 | 研究 agent 中间产物,之后要重编号 |
REMOVE | 引用整个删掉 | 不发 | Discord / 公开 Slack bot,链接不该外泄 |
三种模式都会记 seen_citations(:499)——只是 cited_documents_in_order 仅在 HYPERLINK 下填充。
选择点在主循环:include_citations 为真用 HYPERLINK,为假用 REMOVE(chat/llm_loop.py:789-794)。
8.2 流式里的三个坑
坑一:token 边界会劈开引用。 模型可能先吐 [,再吐 1]。处理器用 possible_citation_pattern(:204-206)识别"可能是半个引用"的尾巴,把它扣住不往下游发,直到能判定为止(:417-421)。
坑二:正则回溯能烧掉一个 CPU 核。 这个模式内层写成 \d+(?:, ?\d+)* 而不是看起来更自然的 (?:\d+,? ?)*。源码注释(:195-203)给出了完整推理:后者把一个无界量词 \d+ 嵌进另一个无界量词,一串连续数字有指数多种切分方式;一旦最终没能匹配上尾部的 $,引擎会 O(2^n) 地回溯。逗号分隔的写法对同一串数字只有唯一解析,保持线性。
坑三:代码块里的 [1] 不是引用。 in_code_block(:58-61)数三反引号的奇偶,奇数说明正处在代码块里,此时跳过引用处理(:331)。同一段还顺手给无语言标记的代码块补上 plaintext(:312-322)。
8.3 顺序与去重
HYPERLINK 分支里的产出顺序是刻意的(:370-382):先发引用前的正文 → 再发 CitationInfo → 最后发格式化后的引用文本。注释说明理由:前端要在拿到 [[n]](link) 这个 token 之前就拿到元数据,才能立刻渲染出来。
去重有两层:
recent_cited_documents挡住短距离内重复引同一篇文档的CitationInfo(:509-511)。- 这个集合在连续 5 个非引用字符后自动清空(
:361-362),所以隔远了再引会重新发一次。
REMOVE 模式还额外处理空白:删掉 [1] 后如果会留下双空格、或空格顶在标点前,就把前面的尾随空格 rstrip 掉(:398-409)。
8.4 编号从哪来、怎么不撞车
tool_runner._run_tools_in_parallel
│ starting_citation_num = next_citation_num
├─ 搜索工具 A ── override_kwargs.starting_citation_num = 1
│ starting_citation_num += 100
├─ 搜索工具 B ── override_kwargs.starting_citation_num = 101
▼
工具内 convert_inference_sections_to_llm_string(citation_start=…)
▼
ToolResponse.rich_response.citation_mapping {号: document_id}
▼
update_citation_processor_from_tool_response → 号: SearchDoc
▼
DynamicCitationProcessor.update_citation_mapping
并行工具各自预留 100 个号位(tools/tool_runner.py:316, 377),这是防撞车的全部机制——注释明说是估算(Estimate: reserve 100 citation slots per search tool)。
update_citation_processor_from_tool_response(chat/citation_utils.py:9-52)负责最后一跳:只对 CITEABLE_TOOLS_NAMES 里的工具生效,把 {号: document_id} 用 search_docs 反查成 {号: SearchDoc}。
update_citation_mapping(citation_processor.py:215-246)默认不覆盖已有键。注释给了具体场景:open_url 的结果可能和 web search 撞号,这时该保留 web search 那份(它带摘要)。
8.5 后处理:重编号和剥离
预留 100 个号位的代价是编号稀疏(1、101、201…)。collapse_citations(citation_utils.py:99-220)在最终产出时把它们压回最小连续编号,且按 document_id 复用已有编号——同一篇文档在前后两段里应该是同一个号。
另一条是 remove_answer_citations(chat/process_message.py:2118-2133):从已渲染好的答案里剥掉 [[1]](url) 形式的链接,产出 answer_citationless。它不用正则匹配整个链接,而是先用 _CITATION_LINK_START_PATTERN = re.compile(r"\s*\[\[\d+\]\]\(")(:1907)找起点,再手动扫括号配对找结尾——因为 URL 里可以合法包含括号,纯正则会截错。这个无引用版本被存库并喂给 Slack bot 等场景(:1992、onyxbot/slack/handlers/handle_buttons.py:266)。
9. 巧妙之处(可以偷走的技术 )
-
预取一次、关掉 session、再并行。 所有 DB 数据(ACL、embedding 模型、联邦函数、Slack token)在一个短命 session 里取完,之后的并行 worker 零数据库连接(
search_tool.py:680-762)。这是把"并行度"和"连接池大小"解耦的标准解法。 -
去重时加权而不是丢弃。 同一条查询被两个扩展器生成 = 更可信,权重相加(
search_tool.py:181-191)。 -
单向闩锁省 LLM 调用。 "对话里没有来源指令"这个结论一旦成立,本轮就不会再变,于是直接关掉后续判定(
search_tool.py:649)。用不变量换 token。 -
把 0 分理解成最小值裁剪。 OpenSearch README 第 20-26 行那段推理——"如果真去算,那个分数必然是最低的,而且会推高其他分数"——是理解混合检索归一化的通用心智模型,不限于 OpenSearch。
-
正则的复杂度是可以被注释救回来的。
citation_processor.py:195-203把"为什么不能这样写"的完整推理留在了代码里。这类知识一旦丢失,下一个人一定会把它改回去。 -
每篇文档最多算 3 个 chunk。
MAX_CHUNKS_FOR_RELEVANCE防的是"标题好或整体强匹配的文档淹没其他结果"(constants.py)。这是一个便宜的多样性保证。 -
保守方向的失败。 展开失败退回原 section、删减失败丢掉整个来源、EE 缺失退化 成 no-op——三处失败都朝"少给结果"而不是"多给结果"的方向倒。
10. 边界与局限(诚实版)
-
LLM 调用密集。 一次
internal_search至少还要额外烧:语义改写 1 次 + 关键词扩展 1 次 + 范围判定 1 次 + 文档选择 1 次 + 每篇展开各 1 次。缓存和闩锁能省掉前三项的重复,省不掉首次。 -
引用号 100 位预留是估算。 单次搜索返回超过 100 个不同文档就会撞号(
tool_runner.py:383)。代码没有对这个上界做检查。 -
Vespa 侧
semantic_retrieval未实现,直接抛NotImplementedError(vespa_document_index.py:993-999);keyword_retrieval走的是admin_searchprofile,主要服务管理界面。 -
两套后端语义不完全等价。
project_id_filter在 OpenSearch 是"限定"、在 Vespa 是"扩大"(FILTER_SEMANTICS.md:89-91);时间衰减在 Vespa 由 schema 下推、在 OpenSearch 必须回到应用层补(README 第 52-54 行)。切换后端会改变排序结果。 -
字段级删减的粒度很粗。 只要某 source 有一个 sync 型 cc_pair,该 source 全部 chunk 都过删减(
post_query_censoring.py:15-33的 NOTE)。这是拿精度换"不必对每个 chunk 反查 cc_pair"。 -
重载后的查询列表会缩水。 实时流显示扩展后的查询,重载 从原始
tool_call_arguments重建(session_loading.py:595-596)。 -
inference_sections_from_ids是死代码,顶着# TODO: This is unused code.(search_runner.py:166)。 -
IndexRetrievalFilters尚未接入,其安全默认值(缺省只搜公开文档)目前不生效(interfaces_new.py:176-178的 TODO)。
11. 代码地图(导航索引)
| 主题 | 文件路径(相对克隆根) | 符号名 |
|---|---|---|
| 工具入口与编排 | backend/onyx/tools/tool_implementations/search/search_tool.py | SearchTool.run |
| 工具 schema | 同上 | SearchTool.tool_definition |
| 扩展 + 范围判定 | 同上 | _expand_queries_and_decide_scope、QueryExpansionAndScope |
| 查询去重加权 | 同上 | deduplicate_queries |
| token 预算裁剪 | 同上 | _trim_sections_by_tokens、_estimate_section_tokens |
| 范围回灌提示 | 同上 | _build_scope_note |
| 单查询检索入口 | 同上 | _run_search_for_query |
| 加权 RRF | backend/onyx/tools/tool_implementations/search/search_utils.py | weighted_reciprocal_rank_fusion |
| section 展开 / 合并 | 同上 | expand_section_with_context、merge_overlapping_sections |
| 权重与常量 | backend/onyx/tools/tool_implementations/search/constants.py | LLM_SEMANTIC_QUERY_WEIGHT、RRF_K_VALUE、MAX_CHUNKS_FOR_RELEVANCE |
| 结果 → JSON + 引用号 | backend/onyx/tools/tool_implementations/utils.py | convert_inference_sections_to_llm_string |
| 检索管线 | backend/onyx/context/search/pipeline.py | search_pipeline |
| 过滤器组装 | 同上 | _build_index_filters |
| 相邻 chunk 合并 | 同上 | merge_individual_chunks |
| 并行分叉 | backend/onyx/context/search/retrieval/search_runner.py | search_chunks |
| 混合 / 关键词检索 | 同上 | _embed_and_hybrid_search、_keyword_search |
| 管线内融合 | 同上 | combine_retrieval_results |
| ACL 过滤器构建 | backend/onyx/context/search/preprocessing/access_filters.py | build_access_filters_for_user |
| 用户 ACL 串 | backend/onyx/access/access.py | _get_acl_for_user、get_acl_for_user |
| EE 字段级删减 | backend/ee/onyx/external_permissions/post_query_censoring.py | _post_query_chunk_censoring |
| EE 挂载惯用法 | backend/onyx/utils/variable_functionality.py | fetch_ee_implementation_or_noop |
| 索引能力 mixin | backend/onyx/document_index/interfaces_new.py | DocumentIndex、HybridCapable、IdRetrievalCapable |
| 后端选型 | backend/onyx/document_index/factory.py | get_default_document_index、get_all_document_indices |
| Vespa 检索 | backend/onyx/document_index/vespa/vespa_document_index.py | VespaDocumentIndex.hybrid_retrieval、VespaIndexPair |
| Vespa YQL / 过滤 | backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.py | build_vespa_filters |
| Vespa 排序 profile | backend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd.jinja | default_rank、recency_bias、hybrid_search_semantic_base_{dim} |
| OpenSearch 检索 | backend/onyx/document_index/opensearch/opensearch_document_index.py | OpenSearchDocumentIndex.hybrid_retrieval |
| OpenSearch 查询构造 | backend/onyx/document_index/opensearch/search.py | DocumentQuery.get_hybrid_search_query、_get_search_filters |
| 归一化权重 | 同上 | _get_hybrid_search_normalization_weights、get_normalization_pipeline_name_and_config |
| OpenSearch 取舍笔记 | backend/onyx/document_index/opensearch/README.md | (散文,读"How Hybrid queries work" / "On time decay and boosting") |
| 过滤语义规范 | backend/onyx/document_index/FILTER_SEMANTICS.md | (散文,读"Knowledge scope rules") |
| 文档 ID 清洗 | backend/onyx/document_index/opensearch/string_filtering.py | filter_and_validate_document_id |
| Slack 联邦检索 | backend/onyx/context/search/federated/slack_search.py | slack_retrieval、convert_slack_score |
| Web 搜索 provider | backend/onyx/tools/tool_implementations/web_search/providers.py | build_search_provider_from_config、provider_requires_api_key |
| 流式引用处理 | backend/onyx/chat/citation_processor.py | DynamicCitationProcessor.process_token、_process_citation、in_code_block |
| 引用映射注入 | backend/onyx/chat/citation_utils.py | update_citation_processor_from_tool_response、collapse_citations |
| 引用剥离 | backend/onyx/chat/process_message.py | remove_answer_citations、_CITATION_LINK_START_PATTERN |
| 引用号分配 | backend/onyx/tools/tool_runner.py | _merge_tool_calls、MERGEABLE_TOOL_FIELDS |