跳到主要内容

数据截至 (上游 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-587SearchTool.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_scopesearch_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 —— 从对话里判断该限定到哪些 DocumentSourcesecondary_llm_flows/source_filter.py:55)。

两个省钱开关藏在这个函数里,值得单独看:

  1. 扩展结果缓存。 self._cached_expansion 存住上次的扩展;同一轮对话里重复调用 internal_search 时,扩展是 source-agnostic 的,可以直接复用(search_tool.py:834-839)。
  2. 范围判定的单向闩锁。 self._scope_decision_settled = plan_scope is Nonesearch_tool.py:649)——一旦某次判定发现"对话里没有来源指令",本轮后续调用就再也不跑这个 LLM 了。注释给的理由很干脆:对话中途不可能凭空冒出一个来源指令。

decide_search_scope 还有一个廉价短路:连接的 source 少于两个时直接返回 Nonesource_filter.py:74-75),没什么可选的就别烧 token。

3.2 权重表:四类查询,四个权重

扩展完的查询不是平等的。权重写死在 search/constants.py,注释里明说"不给用户调,用户调不好":

查询来源权重常量hybrid_alpha
专门改写的语义查询LLM_SEMANTIC_QUERY_WEIGHT1.3默认(0.5)
关键词扩展查询LLM_KEYWORD_QUERY_WEIGHT1.00.2
模型自己给的原始查询LLM_NON_CUSTOM_QUERY_WEIGHT0.7默认
用户原话ORIGINAL_QUERY_WEIGHT0.5默认

hybrid_alpha 是向量分和关键词分的配比,越小越偏关键词。关键词查询用 0.2(KEYWORD_QUERY_HYBRID_ALPHA),刚好会在下游被翻译成 QueryType.KEYWORD 走另一套打分(见 §4.3)。

去重发生在权重分组之后。deduplicate_queriessearch_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_notesearch_tool.py:155-167 + search_tool.py:877-884
落库的工具调用参数模型的原始 queriestool_call_arguments=tool_call.tool_argsllm_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_fusionsearch/search_utils.py:28-...)的公式是:

RRF_score(item) = Σ over all rankers of: weight / (k + rank(item))

k = RRF_K_VALUE = 50constants.py)。k 越大,头部名次的优势越平,越强调"在多个排名里都出现过"这件事。融合的 ID 是 f"{chunk.document_id}_{chunk.chunk_id}"search_tool.py:1044)——粒度是 chunk 不是文档。

融合完立刻做一次相邻合并merge_individual_chunkspipeline.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 个 chunkMAX_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_safesearch_tool.py:1138-1161)——任何异常都吞掉并退回原 section。展开失败不该让整次搜索失败。

3.6 结果渲染:引用号在这里诞生

convert_inference_sections_to_llm_stringbackend/onyx/tools/tool_implementations/utils.py:29-125)是"文档变引用号"的地方。它两遍扫描:

  1. 第一遍按 document_id 分配引用号,同一文档的多个 section 共用一个号utils.py:51-57)。
  2. 第二遍构造 JSON,每条带 document(就是引用号)、titlecontent 等字段。

返回值是 (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_stopwordspipeline.py:317)单独产出 query_keywords,因为 BM25 的标题打分对停用词特别敏感——schema 注释里专门提过这点(见 §6.3)。

4.2 _build_index_filters:过滤器在这里定型

pipeline.py:40-142。它把五个来源的约束揉成一个 IndexFilters

来源字段备注
用户/前端选择source_typetagstime_cutoffdocument_set会被复核权限
Persona 配置persona_document_setspersona_time_cutoff用户没指定时兜底
项目/助手project_id_filterpersona_id_filterattached_document_idshierarchy_node_ids见 §6.4 的"知识范围"语义
用户身份access_control_listACL 串,见 §5
租户tenant_id仅多租户模式

这里补了一个真实的越权洞。 当调用方自带 document_set 名单时,函数会重新查一遍这个用户是否真有权访问这些 document set,不通过就抛 INSUFFICIENT_PERMISSIONSpipeline.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

三个细节值得记:

  1. 纯关键词分支能省掉一次 embedding 调用,但注释警告只有 OpenSearch 侧的生产者会设 hybrid_alpha=0.0,Vespa 走这条会抛 NotImplementedErrorsearch_runner.py:134-144,对照 vespa_document_index.py:993-999)。
  2. hybrid_alpha 在这里变成枚举query_type = QueryType.KEYWORD if hybrid_alpha <= 0.2 else QueryType.SEMANTICsearch_runner.py:65)。所以工具层设的 0.2 一路传下来,最终选中的是 Vespa 的 hybrid_search_keyword_base_* 排序 profile。
  3. 来源全是联邦时不跑本地检索normal_search_enabled 检查 source 过滤集合减去联邦源后是否还有剩(search_runner.py:129-131)。

融合用 combine_retrieval_resultssearch_runner.py:27-48):按 (document_id, chunk_id) 去重,同 key 保留分数高的那份,再按分数排序。注意这是管线内部的融合(同一条查询的多个源),跟 §3.4 工具层跨查询的 RRF 不是一回事。

inference_sections_from_idssearch_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_implementationaccess.py:130-134)换成含用户组、外部组的更长版本。文档侧对应 DocumentAccess.to_acl()——检索时的判定是两个集合有交集即放行

build_access_filters_for_usercontext/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-)本身有三个可借鉴之处:

做法依据为什么
匿名用户直接丢掉全部需删减来源的 chunkpost_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关键方法
SchemaVerifiableverify_and_create_index_if_necessary175
Indexableindex205
Deletabledelete244
Updatableupdate280
IdRetrievalCapableid_based_retrieval312
HybridCapablehybrid_retrieval / keyword_retrieval / semantic_retrieval348
RandomCapablerandom_retrieval432

拆开的价值是类型层面的按需依赖——检索路径只需要 HybridCapable,索引路径只需要 Indexable,而不用整个接口。文件顶部还有一条术语澄清,值得先读:Onyx 说的 "Document" 是整篇文档,但索引里存的对象其实是 chunkinterfaces_new.py:17-21)。

还有一个安全默认值藏在 IndexRetrievalFiltersinterfaces_new.py:170-187):access_control_list 默认是 frozenset({PUBLIC_DOC_PAT}) 而不是空集——忘记传就只能搜到公开文档,而不是搜到全部。(注释同时说明这个类目前还没接上。)

6.2 选型:谁在跑

document_index/factory.py 做两件不同的事,别搞混:

  • get_default_document_indexfactory.py:96-119)—— 检索用哪个后端。按 DB 里的 get_opensearch_retrieval_state 开关二选一。
  • get_all_document_indicesfactory.py:122-151)—— 写入要写哪几个后端。迁移期可能同时写 Vespa 和 OpenSearch,注释要求 Vespa 排第一,因为冲突时假定 Vespa 状态更新(factory.py:128-132)。

两边返回的都不是单个索引,而是 VespaIndexPair / OpenSearchIndexPair —— 主索引 + 副索引(换 embedding 模型时的重建目标)。所有检索方法都直接委托给 _primaryvespa_document_index.py:1315-1331opensearch_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_biasmax(1 / (1 + decay_factor * age), 0.75),衰减地板 0.75222
aggregated_chunk_boost信息量分类器给的聚合加权216

hybrid_search_semantic_base_{dim}(第 229 行起)分两相:first-phase 只用向量(第 245 行),这样完全没有关键词命中的文档也有机会进入候选;global-phase 才做 alpha 加权 + 上述三个乘法 boost(第 249-281 行),rerank-count: 1000

hybrid_retrievalvespa_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 代码里做。

理由分两层:

  1. 归一化之前不能 boost。 embedding 分数不是 01 均匀分布,通常密集聚在 0.60.8,而且随模型和查询漂移。README 第 29-33 行举了个例子:给一个 0.6~0.8 区间的分数打 50% 折扣,它不会落到第 50 百分位,而是直接掉到 0.6 以下变成最差匹配。加法 boost 同理。
  2. 归一化之后没机会 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_querysearch.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_idAND(仅多租户)
ACLaccess_control_list组内 OR,与其他 AND
收窄source_typetagstime_cutoff各自组内 OR,与其他 AND
知识范围document_setattached_document_idshierarchy_node_idspersona_id_filterproject_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 整个被置 Nonesearch_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_datasearch_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_scorefederated/slack_search.py:987-993)把 Slack 分数除以 90000 再钳到 [0,1]。函数注释很坦白:这只影响 UI 和 LLM 看到的排序,不影响裁剪,对答案质量影响很小。
  • 范围判定会关掉它。 如果 resolved_scope 里没有 DocumentSource.SLACK,token 被直接置 Nonesearch_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 keysearxng_client.py
Exaexa_client.py
Brave超时、国家、语言、safesearch、freshnessbrave_client.py
Serperserper_client.py
Tavilysearch_depth、topic、countrytavily_client.py
Google PSE额外要 search engine id(cx)google_pse_client.py

provider_requires_api_keyproviders.py:62-67)单独存在,就是为了给 SearXNG 开口子——它靠公共搜索引擎,本身不需要 key。

抓正文(content provider)是另一条线:Onyx 自带爬虫、Firecrawl、Exa、Tavily Extract 四选一,配不上就退回自带爬虫(providers.py:196-215)。


8. 引用回写:[1] 怎么变成可点链接

8.1 三态模式

DynamicCitationProcessorchat/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_responsechat/citation_utils.py:9-52)负责最后一跳:只对 CITEABLE_TOOLS_NAMES 里的工具生效,把 {号: document_id}search_docs 反查成 {号: SearchDoc}

update_citation_mappingcitation_processor.py:215-246)默认不覆盖已有键。注释给了具体场景:open_url 的结果可能和 web search 撞号,这时该保留 web search 那份(它带摘要)。

8.5 后处理:重编号和剥离

预留 100 个号位的代价是编号稀疏(1、101、201…)。collapse_citationscitation_utils.py:99-220)在最终产出时把它们压回最小连续编号,且按 document_id 复用已有编号——同一篇文档在前后两段里应该是同一个号。

另一条是 remove_answer_citationschat/process_message.py:2118-2133):从已渲染好的答案里剥掉 [[1]](url) 形式的链接,产出 answer_citationless。它不用正则匹配整个链接,而是先用 _CITATION_LINK_START_PATTERN = re.compile(r"\s*\[\[\d+\]\]\("):1907)找起点,再手动扫括号配对找结尾——因为 URL 里可以合法包含括号,纯正则会截错。这个无引用版本被存库并喂给 Slack bot 等场景(:1992onyxbot/slack/handlers/handle_buttons.py:266)。


9. 巧妙之处(可以偷走的技术)

  1. 预取一次、关掉 session、再并行。 所有 DB 数据(ACL、embedding 模型、联邦函数、Slack token)在一个短命 session 里取完,之后的并行 worker 零数据库连接(search_tool.py:680-762)。这是把"并行度"和"连接池大小"解耦的标准解法。

  2. 去重时加权而不是丢弃。 同一条查询被两个扩展器生成 = 更可信,权重相加(search_tool.py:181-191)。

  3. 单向闩锁省 LLM 调用。 "对话里没有来源指令"这个结论一旦成立,本轮就不会再变,于是直接关掉后续判定(search_tool.py:649)。用不变量换 token。

  4. 把 0 分理解成最小值裁剪。 OpenSearch README 第 20-26 行那段推理——"如果真去算,那个分数必然是最低的,而且会推高其他分数"——是理解混合检索归一化的通用心智模型,不限于 OpenSearch。

  5. 正则的复杂度是可以被注释救回来的。 citation_processor.py:195-203 把"为什么不能这样写"的完整推理留在了代码里。这类知识一旦丢失,下一个人一定会把它改回去。

  6. 每篇文档最多算 3 个 chunk。 MAX_CHUNKS_FOR_RELEVANCE 防的是"标题好或整体强匹配的文档淹没其他结果"(constants.py)。这是一个便宜的多样性保证。

  7. 保守方向的失败。 展开失败退回原 section、删减失败丢掉整个来源、EE 缺失退化成 no-op——三处失败都朝"少给结果"而不是"多给结果"的方向倒。


10. 边界与局限(诚实版)

  • LLM 调用密集。 一次 internal_search 至少还要额外烧:语义改写 1 次 + 关键词扩展 1 次 + 范围判定 1 次 + 文档选择 1 次 + 每篇展开各 1 次。缓存和闩锁能省掉前三项的重复,省不掉首次。

  • 引用号 100 位预留是估算。 单次搜索返回超过 100 个不同文档就会撞号(tool_runner.py:383)。代码没有对这个上界做检查。

  • Vespa 侧 semantic_retrieval 未实现,直接抛 NotImplementedErrorvespa_document_index.py:993-999);keyword_retrieval 走的是 admin_search profile,主要服务管理界面。

  • 两套后端语义不完全等价。 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.pySearchTool.run
工具 schema同上SearchTool.tool_definition
扩展 + 范围判定同上_expand_queries_and_decide_scopeQueryExpansionAndScope
查询去重加权同上deduplicate_queries
token 预算裁剪同上_trim_sections_by_tokens_estimate_section_tokens
范围回灌提示同上_build_scope_note
单查询检索入口同上_run_search_for_query
加权 RRFbackend/onyx/tools/tool_implementations/search/search_utils.pyweighted_reciprocal_rank_fusion
section 展开 / 合并同上expand_section_with_contextmerge_overlapping_sections
权重与常量backend/onyx/tools/tool_implementations/search/constants.pyLLM_SEMANTIC_QUERY_WEIGHTRRF_K_VALUEMAX_CHUNKS_FOR_RELEVANCE
结果 → JSON + 引用号backend/onyx/tools/tool_implementations/utils.pyconvert_inference_sections_to_llm_string
检索管线backend/onyx/context/search/pipeline.pysearch_pipeline
过滤器组装同上_build_index_filters
相邻 chunk 合并同上merge_individual_chunks
并行分叉backend/onyx/context/search/retrieval/search_runner.pysearch_chunks
混合 / 关键词检索同上_embed_and_hybrid_search_keyword_search
管线内融合同上combine_retrieval_results
ACL 过滤器构建backend/onyx/context/search/preprocessing/access_filters.pybuild_access_filters_for_user
用户 ACL 串backend/onyx/access/access.py_get_acl_for_userget_acl_for_user
EE 字段级删减backend/ee/onyx/external_permissions/post_query_censoring.py_post_query_chunk_censoring
EE 挂载惯用法backend/onyx/utils/variable_functionality.pyfetch_ee_implementation_or_noop
索引能力 mixinbackend/onyx/document_index/interfaces_new.pyDocumentIndexHybridCapableIdRetrievalCapable
后端选型backend/onyx/document_index/factory.pyget_default_document_indexget_all_document_indices
Vespa 检索backend/onyx/document_index/vespa/vespa_document_index.pyVespaDocumentIndex.hybrid_retrievalVespaIndexPair
Vespa YQL / 过滤backend/onyx/document_index/vespa/shared_utils/vespa_request_builders.pybuild_vespa_filters
Vespa 排序 profilebackend/onyx/document_index/vespa/app_config/schemas/danswer_chunk.sd.jinjadefault_rankrecency_biashybrid_search_semantic_base_{dim}
OpenSearch 检索backend/onyx/document_index/opensearch/opensearch_document_index.pyOpenSearchDocumentIndex.hybrid_retrieval
OpenSearch 查询构造backend/onyx/document_index/opensearch/search.pyDocumentQuery.get_hybrid_search_query_get_search_filters
归一化权重同上_get_hybrid_search_normalization_weightsget_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.pyfilter_and_validate_document_id
Slack 联邦检索backend/onyx/context/search/federated/slack_search.pyslack_retrievalconvert_slack_score
Web 搜索 providerbackend/onyx/tools/tool_implementations/web_search/providers.pybuild_search_provider_from_configprovider_requires_api_key
流式引用处理backend/onyx/chat/citation_processor.pyDynamicCitationProcessor.process_token_process_citationin_code_block
引用映射注入backend/onyx/chat/citation_utils.pyupdate_citation_processor_from_tool_responsecollapse_citations
引用剥离backend/onyx/chat/process_message.pyremove_answer_citations_CITATION_LINK_START_PATTERN
引用号分配backend/onyx/tools/tool_runner.py_merge_tool_callsMERGEABLE_TOOL_FIELDS