解析 ingest 层(Docling 结构化解析)和 retrieval 层(稀疏+稠密混合搜索、Cross-Encoder 重排)的工程决策点。
企业级 RAG 工程——三部曲第二部分
第一部分回顾
第一部分论证了大多数企业级 RAG 系统在摄取层就失败了——远在任何查询发出之前——而解决之道在于布局感知的解析,而非更好的模型。
我们构建了那个阶段:启用 HeadingHierarchyOptions 的 Docling 以恢复章节结构、generate_parsed_pages 以保留字体样式信号、HybridChunker 以在结构边界而非固定 token 数处分片,以及贯穿每个 chunk 的元数据:页码、文件名,以及该 chunk 所属的标题面包屑。
结果是索引中结构得以保留。表格没有被压平成单一文本流。标题紧紧依附于它们所管辖的内容。Chunk 自身就有意义。
那是写路径。本文讲的是读路径——当第一个查询到来时会发生什么。
因为一个语料库可以被完美索引,却仍然无法返回正确的 chunk。
击溃纯语义搜索的查询
几乎所有 RAG 教程中的默认检索模式只有三行:嵌入问题、执行最近邻搜索、返回前 k 个结果。在概念验证中效果足够好,以至于大多数团队就这样上线了。
然后用户搜索了 WH-1000XM5。
系统返回了无线耳机。所有的无线耳机。不是那一款。
这不是调参问题,再多的 prompt 工程也修复不了。这是嵌入工作方式的本质属性。
嵌入模型将一段文本压缩成固定宽度的向量,该向量编码了文本的主旨。型号代码不表达任何主旨。它是一个不透明的字符串,没有分布意义,它在向量空间中占据的位置几乎完全由周围的词决定。两段都讨论无线耳机的 chunk 彼此靠近,无论它们各自具体命名的是哪一款。
同样的失败出现在任何精确标识符上:零件号、错误代码、控制引用、合同条款、内部项目名。企业级语料库中充斥着这些,而用户经常用它们来搜索。
词法搜索具有完全相反的特征。它匹配 token 并按词项统计排序,因此在第一跳就能找到 WH-1000XM5。而当用户问"电池能用多久"而页面只说"播放时间"时,它完全失败——因为没有共同的 token 可以匹配。
两种检索器没有优劣之分。它们在不同的东西上失败。这就是同时运行两者的全部理由。
为什么不能简单运行两个再把分数相加
运行两个检索器很容易。合并它们的结果才是混合搜索真正被决定的地方,而显而易见的方法以一种值得精确理解的方式失败了。
两个分数不可比
余弦距离是有界的。对于归一化向量,它落在已知范围内,一个给定值在跨查询和跨语料库时意义大致相同。
词法排序函数无界。Postgres 的 ts_rank_cd 和 BM25 都产生无界的正数,其大小取决于词频、匹配接近度、文档长度以及被搜索语料库的统计信息。再索引一万个文档,同一文档对同一查询的得分就会不同。
所以一个检索器返回 0.83,另一个返回 24.7,没有任何常数能调和它们——因为两个量表之间的关系不是固定的。
先归一化反而更糟
通常的变通方法是 min-max 归一化:将每个列表缩放到 0 到 1 的范围,然后用权重混合。
这引入了一个更微妙的问题。归一化是在返回的候选上计算的,而不是在整个语料库上。如果词法臂返回的 50 个文档都是弱匹配,这 50 个中最强的那个会归一化为 1.0——与完美匹配的得分相同。
归一化恰恰破坏了你试图保留的信号,而且它悄无声息地这样做——恰恰是在检索器没有任何好东西可提供的情况下。
Rank Fusion 绕过了问题而非解决了问题
互惠排名融合(Reciprocal Rank Fusion)由 Cormack、Clarke 和 Büttcher 在 SIGIR 2009 上提出,它采取了这样的立场:分数不可挽救,干脆全部丢弃。每个文档按其在每个列表中的位置评分:
RRF(d) = Σ 1 / (k + rank_i(d))
i
rank_i(d) 是文档在列表 i 中的 1-indexed 位置,k 是一个平滑常数。原论文在试点研究中固定 k = 60,发现它在 TREC 集合上接近最优,且对具体数值不特别敏感。现在这是 Elasticsearch 和 OpenSearch 中的默认值。
因为它只消费排名位置,RRF 可以融合来自任何检索器的任意数量排名列表,无论它们如何评分、以何种方式评分。
常数 60 的作用
曲线在顶部刻意做平。从第 1 名移到第 2 名只花费 1.6% 的分数。从第 1 名移到第 20 名花费约 24%。
结果是:一致性胜过置信度。一个在两个检索器中都排名第 2 的 chunk 得分约 0.0322。一个在一个检索器中排名第 1、在另一个中缺失的 chunk 得分 0.0164。两个检索器都找到的那个 chunk 以两倍因素胜出,尽管没有一个把它排在第 1。
k = 0 时公式简化为纯互惠排名,第 1 名得 1.0,第 2 名得 0.5,单一检索器的首选结果主导一切。60 抑制了这一点,直到独立信号之间的共识成为决定性因素。
两个检索臂、融合和载荷获取在一次数据库往返中完成。
替代方案是两次查询、两个结果集都被拉到应用层、在 Python 中融合。这花费两次往返加一个合并循环,而且把排序逻辑移到了持有数据的层之外。
数据库可以直接表达 RRF。ROW_NUMBER() 产生排名。FULL OUTER JOIN 产生并集。算术是每行两次除法和一次加法。这些行本来就在那里——让数据库做合并比把它们搬出去在别处合并要划算得多。
# 从问题的词构建 OR 查询,完全在数据库内完成。
# 与存储索引相同的词干化和停用词处理。
ANY_TSQUERY = r"""CAST(array_to_string(ARRAY(
SELECT '''' || replace(replace(lexeme, E'\\', E'\\\\'), '''', '''''') || ''''
FROM unnest(tsvector_to_array(to_tsvector('english', :question))) AS lexeme
), ' | ') AS tsquery)"""
HYBRID_SQL = f"""
WITH keyword AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY ts_rank_cd(fts, {ANY_TSQUERY}) DESC) AS rank
FROM chunks
WHERE fts @@ {ANY_TSQUERY}
ORDER BY ts_rank_cd(fts, {ANY_TSQUERY}) DESC
LIMIT :pool
),
semantic AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> CAST(:qv AS vector)) AS rank
FROM chunks
ORDER BY embedding <=> CAST(:qv AS vector)
LIMIT :pool
)
SELECT c.id, c.content, c.page, c.heading,
COALESCE(1.0/(60+k.rank), 0) + COALESCE(1.0/(60+s.rank), 0) AS score,
k.rank AS keyword_rank,
s.rank AS vector_rank
FROM keyword k
FULL OUTER JOIN semantic s USING (id)
JOIN chunks c USING (id)
ORDER BY score DESC
LIMIT :pool;
"""
其中的决策
在数据库中构建词法查询,而不是在 Python 中。显而易见的方法是在 Python 中拆分问题并用 OR 操作符连接词项。但这引入了第二个分词器——而且 Python 没有应用产生存储索引时使用的相同词干化或停用词列表。结果是一个词项与索引内容不匹配的查询。
ANY_TSQUERY 通过将问题传递给 to_tsvector('english', ...),使用与产生存储向量时相同的函数和相同配置,然后 unnest 词素、给每个加引号、再连接起来,从而避免了这个问题。查询端和索引端的分析是相同的,因为它们是同一个调用。
replace 调用转义反斜杠和引号,这样包含其中任一字符的词素就不会破坏带引号的字面量。
用 OR,不用 AND。词项用 OR 操作符连接,使这成为面向召回的:匹配任何词项的 chunk 都是候选。在融合管道中这是正确的。词法臂的职责是浮现出任何合理的内容;精确性在下游处理。
用 FULL OUTER JOIN,不用 INNER JOIN。这是使融合行为正确的结构性决策。
INNER JOIN 只保留两个检索器都找到的 chunk——这恰恰丢弃了混合搜索存在所要捕获的情况。只被词法臂找到的 SKU。只被向量臂找到的改述。LEFT JOIN 任意偏袒某一臂。
使用 FULL OUTER JOIN 时,同时出现在一个列表而不在另一个列表中的块会保留下来,缺失那一侧的排名为 NULL,而 COALESCE(..., 0) 会为缺失侧补零,而不是让 NULL 参与算术运算。单臂命中得分约 0.0164。双臂命中得分最高可达 0.0328。两者都是可检索的;两个检索器都命中的块排名更高。
延迟载荷获取。CTE 仅携带 id 和排名。两个臂都对全表进行排名,如果将内容携带通过它们,就意味着那些即将被丢弃的行也会被物化为大型文本值。在融合后再连接载荷,可以保持中间结果集的精简。
两种不同的深度。pool 控制每个臂搜索的深度以及有多少融合后的候选者存活。请求的 k 控制最终返回多少个文档。这不是同一个参数,如果将请求的 k 绑定到 SQL 最终的 LIMIT,会将候选池限制在用户请求的结果数量内——恰好让重排序器失去它本应重新排序的候选。
收益是能力级别的,而且非常具体。
标识符查询生效了。搜索型号、零件代码或条款引用现在会返回包含该字符串的块,因为词法臂通过精确匹配找到了它。在此之前,这些查询返回的是主题相邻的块,没有其他结果。对于企业语料库来说,这是实际应用中最大的差异。
改述查询仍然有效。向量臂未做改动,因此用不同词汇对文档进行概念性提问,其表现与之前完全相同。混合搜索是增量的——之前有效的功能没有任何一个被停止。
单臂命中不会丢失。由于 FULL OUTER JOIN 的存在,只被一个检索器找到的块仍然能进入候选集。它排名低于两个臂都认可的块,这是正确的优先级,但它是可以被检索到的。INNER JOIN 实现会悄悄丢弃恰恰是运行两个检索器的理由的那些结果。
候选池更宽泛但不会更嘈杂。两个独立的检索器比单独任何一个都能呈现出更多样化的候选集,RRF 则按跨检索器的一致性对它们进行排序,而不是按碰巧产生更大数字的评分函数。
无需维护规模校准。由于 RRF 仅消耗排名位置,不存在随语料库增长而漂移的归一化常数,也不存在入库后需要重新调整的权重。融合行为在 1,000 个块和 100,000 个块上是相同的。
一次往返,而非两次。整个融合发生在数据库内部。没有第二轮查询,没有应用层合并循环,也不存在两个结果集可能来自不同数据状态的窗口。
关于证据的说明:这些都是机械层面的收益,可以通过构造来演示。量化相关性改进需要用标注评估集对两种配置进行对比运行——这是正确的下一步,而且候选深度和阈值参数应该针对标注评估集进行调整,而不是凭直觉。
混合搜索产生了更好的候选集。它并不能产生更好的答案,原因值得直说。
融合分数不是相关性分数
在双检索器系统中,最大可能的 RRF 分数是 2/61——约 0.0328——需要块在两个臂中都排名第一。这个上限由公式决定,而非匹配质量决定。
一个完美回答问题的块和一个只是同时在两个列表中处于良好位置的块,如果它们占据相同的排名,就会获得相同的分数。这个数字告诉你一个块在两个有序列表中的位置。它不能告诉你这个块是否回答了任何问题。
这有一个直接的操作后果:应用于融合分数的任何阈值都是对排名的阈值,这与相关性的阈值不是同一回事,且会随语料库变化而表现不可预测。
重排序器做的是什么
两个检索臂分别对查询和文档进行评分。向量臂分别对它们进行嵌入然后比较结果。词法臂计算词项统计,从不考虑查询和文档的联合情况。这种独立性使它们快速且可索引——但这也是它们的瓶颈。
交叉编码器重排序器在单次前向传递中将查询和文档一起处理。它可以对两者之间的交互进行建模:这个特定的文档是否实际回答了这个特定的问题,而不是它们是否在向量空间中占据附近区域或共享词元。
这每个文档的开销要大得多,这正是它最后才运行的原因——对约 50 个候选者的池进行处理,而非整个语料库。架构是经过深思熟虑的:廉价的有索引检索将数百万个块缩减到数十个,然后昂贵的联合评分对这数十个进行排序。
在这个系统中,这一阶段是 Amazon Bedrock 的 amazon.rerank-v1:0,通过 bedrock-agent-runtime 端点调用。API 每次请求最多接受 1,000 个内联源,因此 50 个候选的池无需分批。
一个真实的质量信号。重排序器的 relevanceScore 是整个管道中唯一一个衡量块是否回答问题的数字。所有上游衡量的都是位置或邻近度。
一个有意义的阈值。因为该分数反映的是相关性而非排名,最小阈值变成了一个有意义的关卡。那些仅靠位置在检索阶段存活但实际上没有回答问题的块,在到达提示词之前就被过滤掉了。
诚实的结果数量可变。请求三个结果可能返回更少——或者没有。在一次针对合规语料库的评估运行中,一个 k=3 的问题返回了得分 0.2067 的单一来源,生成的答案与预期的事实相符。系统没有失败。它在报告该语料库中只有一个块回答了那个问题。
这种行为之所以可能,是因为存在一个相关性分数可以用于阈值过滤。没有它,三个块无论如何都会回来,两个弱的块会连同好的那个一起进入提示词。
更干净的生成上下文。更少、更好的块意味着更短的提示词、更少的 token,以及更少的机会让模型锚定在弱相关段落上。质量关卡在生成阶段和检索阶段都在自我回报。
一条可用的降级路径。因为 RRF 排序在重排序器运行之前就已经存在,重排序失败有一个合理的回退方案:返回截断到 k 的融合结果。这在实践中很重要——重排序端点会限流,在测试期间一个真实的 ThrottlingException 就是这样被吸收而没有导致请求失败。
try:
reranked_results = await rerank_documents(k, question, documents)
except Exception:
logger.exception("rerank failed, falling back to RRF order")
return list(islice(documents, k))
关于这个回退有两点是经过深思熟虑的。它捕获裸 Exception,这是正确的,因为任何重排序失败都应该降级而非失败,枚举客户端库可能抛出的每个异常是一场注定失败的仗。而且它跳过了相关性阈值,因为回退文档没有可过滤的分数——调用者获得最多 k 个按 RRF 排序的块,其中可能包含弱匹配。这是权衡:用较弱的排序得到完整答案,而非没有答案。缺失的分数应该对调用者可见,而不是被静默默认值取代。
没有混合搜索
精确标识符查询会静默失败。这是代价高昂的一个。搜索型号、错误代码或条款引用的用户会得到看起来合理但不包含他们所问内容的返回结果。没有错误,也没有空结果——只有自信地给模型提供错误上下文,然后它生成一个关于错误内容的流畅答案。在有人检查引用之前,这个失败是不可见的。
稀有和领域特定术语会退化。在嵌入模型训练数据中很薄的行话,在向量空间中没有可靠的位置。它会嵌入到它表面相似的东西附近。词法匹配不关心一个词有多常见——它要么出现,要么不出现。
召回率完全取决于一个模型对语义的看法。如果嵌入模型的相似性概念不适合你的领域,没有第二个信号来补偿。每个检索失败都追溯到同一个单点。
否定和精确措辞会模糊。嵌入擅长捕捉主题,但对精确的逻辑结构捕捉较差。两个对同一主题得出相反结论的段落可能在向量空间中彼此靠近。
你根本没有相关性信号。这是根本性的损失。检索评分——无论是融合后的还是原始的——衡量的是位置和邻近度。流水线中没有任何东西在衡量一个 chunk 是否回答了问题。你设定的任何阈值都是在你真正关心的东西之外设定的阈值。
每个查询都精确返回 k 个结果,无论它们质量如何。你的语料库无法回答的问题依然会返回 k 个 chunk,因为检索器总是按位置返回前 k 个。那些 chunk 被送入 prompt,而模型会按模型的方式处理弱相关上下文——它总能拼凑出一些说法。
你无法区分"没有匹配到任何内容"和"匹配到了某些内容"。系统失去了说"我没有这个"的能力,而在合规或客服场景中,这往往是最有价值答案。
列表顶端几乎没有区分度。使得共识融合具有鲁棒性的平坦 RRF 曲线也意味着排名第 1 到第 5 位之间只相差几个百分点。若直接用于服务,这种排序很弱——融合的设计目的是选出一个候选集,而不是产生最终排名。
Prompt 上下文变得更嘈杂。更多的边缘 chunk 到达模型,消耗 token 并与真正回答问题的那个 chunk 争夺注意力。
不带重排序的混合搜索给你一张更宽的网,却没有方法判断捕到了什么。不带混合搜索的重排序给你良好的判断力,但应用在一个可能从未包含正确答案的候选集上。
两者解决的是同一问题的不同半边。检索决定有哪些内容可用于回答。重排序决定哪些内容值得用来回答。
这里描述的一切都是在一台笔记本上的数据库上运行的。第 3 部分将其搬到 AWS:S3 作为文档存储,Amazon OpenSearch Service 作为向量和词义索引,以及一条事件驱动的摄入路径——当文件落入 bucket 时触发,而不是有人运行脚本时。
这一迁移改变了本文融合逻辑所在的位置。FULL OUTER JOIN 和手写的 1/(60+rank) 算术变成了一条搜索流水线声明——OpenSearch 将 RRF 实现为一个可配置 rank_constant 的 score-rank-processor,默认值同样是 60。第 3 部分涵盖了这样做带来的收益、成本,以及迁移对检索质量的影响——以相同的评估集来衡量。
Cormack, Clarke and Büttcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009
Amazon Bedrock Rerank API reference
PostgreSQL text search controls