企业RAG检索:关键词、嵌入与LLM仲裁协同

本章节作为企业文档智能RAG系统构建系列的检索模块核心部分,承接前文的锚点检测流程,聚焦于最终的LLM仲裁环节——通过单次大模型调用完成候选结果的排序与可审计的决策输出。整个检索流程的核心逻辑是「检测器提案,仲裁器决策」,所有环节最终都会生成一份可被合规审计的结构化JSON结果。
检索本质是基于结构化表格的过滤工作,依托解析阶段生成的line_df(文本行数据)和toc_df(目录数据),区分锚点与上下文。锚点检测流程分为三个阶段:并行执行关键词与嵌入检索、聚合为结构化单元(章节、页面或文本块)、最终通过单次LLM调用完成仲裁,本文将聚焦于最后这一关键环节。
本次演示采用经典的《Attention Is All You Need》论文作为测试样本,该论文自带清晰的PDF大纲目录(22个条目,3级深度),内容覆盖编码器、解码器、注意力机制等RAG开发人员熟悉的核心模块,便于我们聚焦于检索方法本身而非特定领域语料的解析。本文默认测试文档自带完整目录,从原始文本中自动重建目录的内容将留待后续系列文章展开。
一、LLM仲裁器:最终的单次大模型调用
前文的锚点检测流程已经生成了各个检索器返回的候选结果,本环节将对这些候选进行最终处理。这里的「候选结果」指的是每个检索器返回的单篇文本段落,包含匹配到的锚点位置、聚合后的结构化单元(章节、页面或文本块)以及周边上下文片段。仲裁器会在单次LLM调用中接收所有候选信息,基于结构化信息完成排序并生成决策理由,最终输出可审计的结果列表。
本小节将覆盖三个核心问题:
- 为什么传统的分数融合方法(如RRF等)会丢失检索器已经提供的关键信号
- 需要向LLM提供什么样的结构化输入信息,才能让它基于理由完成排序
- 需要为每个候选记录哪些信息,才能让审计人员完整复现决策过程
整个流程的核心逻辑可以总结为:检测器提案,仲裁器决策,全程仅需一次LLM调用。
1.1 分数融合并非最优选择
当多个检索器返回候选结果时,开发者通常会倾向于融合它们的评分。但不同检索方法的评分尺度完全不统一:余弦相似度、无上限的BM25得分、共现计数整数,这些数值直接相加毫无意义,即使进行归一化处理也无法解决语义差异——比如0.9的余弦相似度和0.9的归一化BM25得分,对于同一个候选结果代表的含义完全不同。
行业内常用的解决方案是互反排名融合(RRF),它通过忽略原始评分、仅使用排名顺序来规避校准问题:按照标准惯例设置k=20,多个检索器的排名贡献会被累加,仅被单个检索器发现的候选也能获得基础的权重。RRF是许多向量数据库的默认配置,在常见场景下无需调参即可工作。
但RRF会丢失大量关键的决策信号:一个检索器为什么会将某个候选排在前面,背后的原因至关重要。比如目录检索匹配是因为章节标题精准匹配,关键词检索是因为特定词汇共现,嵌入检索是因为页面内容在向量空间中语义相似。RRF将所有这些信息压缩为一个排名索引,不同检索器的共识最终只变成一个数字,而共识背后的具体理由完全消失了。
实际上,专家用户在查看检索结果时,关注的正是章节标题、匹配到的关键词以及匹配位置周边的上下文。我们的实践证明,向LLM提供结构化的上述信息,让它完成排序决策,效果要优于任何分数融合方法。
在实际场景中,我们仍然会在周边工具默认使用RRF时记录该数据,或者当候选池过大无法一次性放入LLM调用时,先用RRF筛选出前200个候选,再将剩余结果送入LLM仲裁。但最终的排序决策权应该属于LLM,而非分数计算公式。
1.2 向LLM提供结构化的任务简报
LLM作为最终的排序层,它的输入不应该是「这里有五篇段落,选出最好的」,而应该是一份结构化的任务简报,每个候选对应一行信息,列出每个检索器发现的具体内容:
candidate_id:稳定的唯一标识(页面+行范围,或章节+行偏移量)methods:发现该候选的检索方法(目录、关键词、嵌入等)section:该候选所属的目录章节,取自toc_dfmatched_keywords:从解析后的查询中提取的、匹配到该候选的关键词snippet:取自line_df的3-5行周边上下文片段
这份简报的内容与专家用户在界面上看到的信息高度一致:顶部是章节标题,正文是匹配到的关键词,下方是匹配位置的上下文片段,远比单纯的余弦相似度排名列表更直观。LLM会对所有候选进行排序,并为每个保留的候选生成一行简短理由,这些理由会直接存入审计追踪日志。每个候选会被分配四种角色之一:
primary:包含核心答案的段落supporting:提供答案所需的补充上下文tangential:相关但优先级较低的内容discarded:LLM判定为无关的内容,需记录丢弃理由用于审计
class CandidateBrief(BaseModel):
candidate_id: str
methods: list[str]
section: str
matched_keywords: list[str]
snippet: str
class CandidateRanking(BaseModel):
candidate_id: str
role: Literal["primary", "supporting", "tangential", "discarded"]
reason: str
def llm_rank(
question: str,
briefs: list[CandidateBrief],
client,
) -> list[CandidateRanking]:
"""Read the structured briefs, return one ranking per candidate."""
...
这是一个最小可运行的仲裁器实现,仅需一次LLM调用即可处理全部候选列表,并返回结构化的输出结果。
def llm_rank(question: str, briefs: list[CandidateBrief], client) -> list[CandidateRanking]:
"""Hand the LLM the structured briefs, get one role + reason per candidate."""
briefs_text = "\n".join(
f"[{b.candidate_id}] section={b.section!r}, methods={b.methods}, "
f"matched={b.matched_keywords}, snippet={b.snippet!r}"
for b in briefs
)
prompt = (
f"Question: {question}\n\nCandidates:\n{briefs_text}\n\n"
"For each candidate, assign a role (primary, supporting, tangential, "
"discarded) and a one-line reason. Use the candidate_id verbatim."
)
return client.responses.parse(
model=model_chat,
input=prompt,
text_format=ArbiterOutput,
).output_parsed.rankings
这种方式相比分数融合的优势主要体现在四个方面:
- LLM能够理解每个检索器选择该候选的具体理由:比如高余弦相似度但无关键词重叠的结果很可能是主题噪声,而目录+关键词的双重匹配则是真实的结构化信号,RRF会将这两种情况都转化为相同的排名数值
- LLM可以对候选进行更细致的分类,而非简单的保留/丢弃,这在答案需要主内容和补充上下文的场景中非常实用
- LLM能够识别内容矛盾:比如同一主题下两篇段落给出了不同的说法,这种情况在带有修订条款的合同、监管文件中非常常见
- 决策理由是纯文本形式,可以直接存入审计追踪日志,无需向合规人员解释类似「rrf_score = 0.0327」这类难以理解的技术参数
该方案的成本仅为一次LLM调用(对于包含10个候选的池,耗时约1秒),比在整个文档上运行嵌入检索更便宜,更重要的是,它能避免生产环境中因错误答案带来的高昂代价。
测试文档上的具体示例:假设查询问题为「该论文使用了哪种位置编码?」,通过目录匹配(直接命中「3.5 Positional Encoding」章节)和全文档关键词检索,共得到四个候选结果,每个候选都会被转换为简报中的一行信息。
LLM读取这四份简报和查询问题后,会返回如下结构化结果:
{
"rankings": [
{
"candidate_id": "page_5_sinusoidal",
"role": "primary",
"reason": "Defines the sinusoidal positional encoding used in the paper"
},
{
"candidate_id": "page_6_learned",
"role": "primary",
"reason": "States the alternative (learned embeddings) the paper compared"
},
{
"candidate_id": "page_2_intro",
"role": "discarded",
"reason": "Mentions the motivation for positional encoding, but no options"
},
{
"candidate_id": "page_3_encoder",
"role": "discarded",
"reason": "Single mention in a different context, off-topic"
}
]
}
最终保留了四个候选中的两个。被丢弃的两个候选虽然有合法的关键词匹配,但它们的上下文片段更偏向于背景介绍而非答案,LLM通过上下文片段做出了判断,并将理由写入了审计日志。
1.3 冲突处理与审计追踪
当不同检索器的结果出现分歧时,LLM需要做出选择。我们总结了三个实用的经验规则:
- 精准匹配目录标题时优先信任目录:章节标题是文档作者亲自撰写的,比如前文示例中的「3.5 Positional Encoding」与查询关键词直接匹配,任何统计方法都不应该覆盖这一明确信号
- 强信号的关键词共现值得信任:当主关键词和次级关键词同时出现在同一行中(比如「位置」+「正弦」,或「位置」+「学习」),即使该结果不在目录缩小的范围内,也几乎可以确定是相关的
- 对仅由嵌入检索发现的候选保持警惕:仅通过嵌入检索发现、没有目录或关键词支持的候选需要仔细检查。有时它可能是关键词检索遗漏的同义词,但更多时候是主题噪声,LLM通过阅读简报通常能够区分这两种情况
结构化简报正是让LLM能够 consistent 地应用这些规则的基础,而分数融合则将每个检索器的决策理由压缩为单一排名索引,直接丢弃了这些关键规则。
每个候选结果都必须是可被审计的。当被问及「为什么这个段落被展示给用户?」时,答案不能是「嵌入相似度为0.78」,而应该是清晰的决策链:哪些检索器发现了该候选、向LLM展示了什么样的简报、LLM分配了什么角色、给出了什么理由。每个候选都需要保留从检索到生成阶段的完整追溯路径。
class CandidateProvenance(BaseModel):
candidate_id: str
methods_that_found_it: list[str]
rank_per_method: dict[str, int]
structural_context: CandidateBrief
llm_role: Literal["primary", "supporting",
"tangential", "discarded"]
llm_reason: str
class AuditedRetrievalResult(BaseModel):
candidates: list[dict]
provenances: list[CandidateProvenance]
methods_run: list[str]
methods_skipped: list[str]
skipped_reasons: dict[str, str]
对于每个最终展示给用户的候选,系统都能够回答以下问题:哪些检索器支持该候选、该候选在每个检索器中的排名如何、LLM看到了什么信息、LLM分配了什么角色以及理由是什么、哪些检索器被跳过以及原因是什么。
以下是前文示例中第5页候选的具体追溯记录:
provenances = [
CandidateProvenance(
candidate_id="page_5_sinusoidal",
methods_that_found_it=["toc", "keywords"],
rank_per_method={"toc": 1, "keywords": 1},
structural_context=CandidateBrief(
candidate_id="page_5_sinusoidal",
methods=["toc", "keywords"],
section="3.5 Positional Encoding",
matched_keywords=["positional", "encoding", "sinusoidal"],
snippet="We use sine and cosine functions of different frequencies...",
),
llm_role="primary",
llm_reason="Defines the sinusoidal positional encoding used in the paper",
),
# ... same structure for the other kept candidate
]
methods_skipped = ["embeddings"]
skipped_reasons = {
"embeddings": "TOC and keywords returned strong reinforcing signals"
}
这不仅是为了调试方便,在合规、法律和审计场景中,这条追溯路径正是证明系统输出合理性的核心文档。没有它,检索管道就是一个黑箱,而黑箱无法通过监管审查。
二、检索方法的选择
2.1 为什么嵌入检索应该放在最后
大多数RAG教程、框架和会议演讲都将基于嵌入的检索作为系统的基础,默认嵌入是首选方案,开发者似乎只需要选择合适的嵌入模型即可。但通过本系列的实践,我们的结论非常明确:嵌入检索在两个表格(目录表toc_df的标题嵌入匹配、文本行表line_df的块嵌入匹配)中都仅处于辅助地位,对于大多数企业文档来说,嵌入并非默认推荐的检索方式。这并非否定嵌入检索的价值,而是基于实际效果重新调整其优先级。
嵌入检索的三个核心缺陷:
- 嵌入会稀释高信号token:当我们对查询「保险法规的L131-1条款是否适用于此?」进行嵌入时,生成的向量是「article」、「insurance」、「code」、「apply」、「L131-1」这些词的平均。其中「L131-1」是整个查询的核心信号,但它只是七个信号中的一个。正确段落的向量可能并非最相似的,而内容关键词匹配却能瞬间找到目标。我们在最小RAG管道中曾遇到过一个著名的epsilon标签平滑失败案例:包含正确答案的页面甚至不在余弦相似度的前三,而获胜的页面更多体现了查询的「视觉风格」,而非具体的信息内容
- 嵌入无法区分相近但不同的概念:「保费」和「免赔额」的向量非常接近,它们都是保险合同中的金额概念,也出现在相似的上下文中。当查询「保费」时,嵌入检索会同时返回提到「保费」和「免赔额」的段落。如果嵌入检索返回的主要是关于「免赔额」的段落,下游的LLM将没有足够的信息来消除歧义。同样的问题也出现在否定词、时间引用以及任何依赖细微词汇变化的语义区分中。嵌入的模糊性既是它的优势也是它的弱点
- 嵌入不理解文档结构:向量数据库是扁平化的文本块集合,它无法感知「这个块属于「定义」章节」或「这个块是附录的一部分」。当文档被分块时,所有的结构信息都被丢弃了。比如当用户询问「保修条款中关于洪水损害的规定是什么?」,嵌入搜索会返回同时提到「保修」和「洪水」的段落,这些段落可能来自「定义」或「免责条款」章节,而非用户真正需要的「保修」章节,而目录推理可以一步直接定位到目标章节
我们可以用测试文档来具体说明:假设用户询问「图2展示了什么?」,这个问题的核心信号是「图2」这个精确标记,其余内容都是 filler。嵌入会将整个查询平均为向量,而正则匹配可以瞬间定位到所有包含精确标记「图2」的行。嵌入检索会根据与查询措辞的「视觉相似度」对页面进行排序,甚至可能不会将真正提到「图2」的页面排在前五名,而正则匹配可以确定性地找到所有包含精确标记的行,将单个高信号token从平均向量中提取出来。
2.2 嵌入检索的适用场景
嵌入检索在三种场景下表现出色:
- 存在词汇不匹配且需要语义改写的场景:比如查询「提前退出」,经过语义改写后会与「提前终止条款」的向量非常接近,而标题关键词匹配和内容关键词匹配可能无法发现这种关联,但块嵌入匹配可以捕捉到。(目录推理也能捕捉到这种关联,原因不同:LLM会理解「提前退出」与「终止」章节存在隐含关联,而非语义相似)
- 概念性或模糊的查询:比如「责任限额是否合理?」,没有明确的关键词可以搜索,嵌入检索可以暴露具有正确概念形状的段落
- 没有目录且没有专业词汇的文档:比如备忘录、电子邮件、博客文章,没有可导航的结构,也没有专业词典可应用
在这三种场景中,嵌入检索并非单独使用,而是会与关键词和目录方法结合(即混合嵌入组合),以弥补自身缺乏结构意识的缺陷。
实际生产环境数据:在一个生产系统中,我们通过逐一移除各个检索方法来评估其贡献,并基于故障模式进行评估:
- 仅使用嵌入检索:准确率71%
- 在适用场景中添加目录检索:准确率提升至84%
- 添加基于专业词典的共现关键词检索:准确率提升至91%
- 添加调度器为每个查询选择合适的方法组合:准确率提升至94%
仅使用嵌入检索与全方法确定性调度之间23个百分点的准确率差距,正是更好的检索策略带来的收益。我们尝试过的所有嵌入模型升级都无法达到这个差距。
HyDE是该原则的一个特殊案例:HyDE会生成一个假设的查询答案,对其进行嵌入,然后与文档块进行余弦相似度匹配。它有效的原因在于,假设的答案包含了查询所缺乏的文档领域词汇。比如查询「我如何提前终止合同?」,LLM生成的假设答案会包含「提前终止、通知期、退出费、书面通知」等词汇,这些词汇会在嵌入空间中与真实段落匹配。在我们的框架中,这一逻辑可以在更早的查询解析阶段通过专业关键词词典实现。HyDE可以看作「隐式的按查询嵌入关键词扩展」,而我们更倾向于「显式的一次性关键词扩展并复用」。两者效果相近,但后者有三个运营优势:
- 关键词集合是可审计的:专家可以清楚地看到哪些词被匹配以及原因
- 成本只需一次性投入,而非每次查询都重新生成
- 构建的是永久资产(专业词典),而非一次性的假设答案
在没有领域专家且无需审计的开放域消费场景中,HyDE仍有小幅优势;但在本系列关注的企业场景中,显式的关键词扩展在实践中表现更优。查询解析模块会详细分解HyDE背后的机制,并解释为什么当文档词汇范围有限时,关键词+同义词的方式更具优势。
2.3 调度决策树
调度器如何为特定查询选择合适的检索方法组合?
def choose_methods(intent, keywords, doc_has_toc,
has_exact_codes=False,
vocabulary_diverges=False):
methods = []
if doc_has_toc and intent in ("qa", "summarization"):
methods.append("toc")
if keywords:
methods.append("co_occurrence")
if has_exact_codes:
methods.append("bm25")
if not methods or vocabulary_diverges:
methods.append("embedding")
return methods
具体的决策流程如下:
- 如果文档有清晰的目录,且查询意图为问答或总结任务,始终运行标题关键词匹配,可选添加标题嵌入匹配。仲裁器会在单次LLM调用中利用目录信息处理细微的标题匹配
- 在文本行表
line_df上,使用解析后的查询关键词和共现权重运行内容关键词匹配,这是主要的检测器,其结果会直接送入仲裁器 - 当预期存在词汇不匹配或查询为概念性问题时,可选在
line_df上并行添加嵌入检索,当关键词信号已经足够清晰时可以跳过该步骤 - LLM仲裁器(第一节所述)会接收所有候选信息,一次性完成排序并生成理由,这是将检测器结果转换为最终列表的单次LLM调用
- 对于候选池过大无法一次性放入LLM调用的超大型文档,可以使用先推理再匹配作为预筛选技巧:先通过一次额外的LLM调用在目录表
toc_df中选择相关章节,再在该范围内运行内容关键词检索,最后由仲裁器对剩余候选进行排序。这种方式仅在规模要求时使用,总共有两次LLM调用而非一次
在生产环境中,大多数查询都会激活关键词+(可选)嵌入检索,并最终由仲裁器完成排序。以下代码使用了旧的"toc_reasoning"和"reason_then_match"标签以保持向后兼容性,从概念上讲,它们分别对应「仲裁器完成推理」和「在仲裁前缩小范围」。后续的集成管道会围绕调度器开发完整的编排逻辑。
常见陷阱:为所有查询硬编码相同的检索策略。比如始终运行嵌入检索,即使文档有清晰的目录且查询包含明确的关键词,这样做不仅增加了不必要的计算量,还会比简单方法产生更多噪声候选。调度器应该具备选择能力,如果它总是选择相同的策略,那么你实际上已经移除了这个选择环节。
def choose_retrieval_strategy(
intent: str,
keywords: list[str],
doc_has_toc: bool,
has_exact_codes: bool = False,
vocabulary_diverges: bool = False,
) -> list[str]:
strategy: list[str] = []
if doc_has_toc and intent in ("qa", "summarization"):
strategy.append("toc_reasoning")
strategy.append("title_keyword_match")
if keywords:
if "toc_reasoning" in strategy:
strategy.append("reason_then_match")
else:
strategy.append("content_keyword_match")
if has_exact_codes:
strategy.append("bm25")
if not strategy or vocabulary_diverges:
strategy.append("chunk_embedding_match" if not doc_has_toc else "hybrid_embedding")
return strategy
# 四个研究该论文的读者可能提出的真实问题,由策略选择器分配不同的策略
doc_has_toc = len(toc_df) > 3
questions = [
{"q": "How does multi-head attention work?",
"intent": "qa", "keywords": ["multi-head", "attention"], "exact": False, "diverges": False},
{"q": "What does Figure 2 show?",
"intent": "qa", "keywords": ["Figure 2"], "exact": True, "diverges": False},
{"q": "Why does this model train faster than RNNs?",
"intent": "qa", "keywords": [], "exact": False, "diverges": True},
{"q": "Summarize the Training section.",
"intent": "summarization", "keywords": ["Training"], "exact": False, "diverges": False},
]
for q in questions:
s = choose_retrieval_strategy(
q["intent"], q["keywords"], doc_has_toc,
has_exact_codes=q["exact"], vocabulary_diverges=q["diverges"],
)
print(f"{q['q']}\n strategy: {s}\n")
三、可靠地返回「未找到」结果
可靠的系统必须能够明确返回「未找到结果」,但这比听起来更困难,因为LLM的训练目标是生成响应,而非拒绝提供答案。系统架构需要在每个阶段都支持「未找到」的逻辑:
- 元数据过滤返回零结果:不执行任何检索,直接告知用户没有文档匹配查询范围
- 目录匹配未找到相关章节:管道进入下一个检索方法
- 所有检索方法返回空或低置信度结果:管道报告「在可用文档中未找到答案」,并说明搜索范围
- 候选结果存在但生成阶段无法提取答案:这部分由生成阶段处理,但检索阶段必须传递足够的元数据,让生成阶段能够保守地处理
3.1 为什么关键词检索可以证明不存在,而嵌入检索不能
不同的检索方法在处理「不存在」的情况时存在根本性的不对称。嵌入检索总是会返回top-k结果,带有连续的相似度分数。比如关于「保费」的页面,对于「免赔额」的查询可能返回0.78的相似度,而关于「免赔额」的页面可能返回0.81的相似度。那么应该在哪里划定「找到」和「未找到」的界限?嵌入空间中没有任何信息表明「这不是正确答案」,只能表明「这个比另一个更相似或更不相似」。你可以设置一个阈值,但这个阈值是任意的,在不同的文档或查询中都会失效。
关键词检索则完全相反。如果你的专业词典覆盖了语料中该主题的所有表达形式(比如「exclusion」、「exclu de」、「non couvert」、「hors champ」、「ne couvre pas」),而文档中没有任何一个出现,那么你可以合理地断言该主题不在此文档中。同样的逻辑适用于金额、日期、代码等。当针对「欧元符号」的正则匹配没有返回任何结果时,就意味着文档中没有相关内容。这种缺失是确凿的证据,而非不确定性。
这正是企业用户使用Ctrl+F搜索时的常规操作:搜索他们期望的术语,如果没有结果,就得出文档不覆盖该主题的结论。这个结论成立的原因是用户知道应该搜索哪些术语。三十年的专业文档处理工作都基于这种方法。一个编码了相同专业词汇的检索管道继承了同样的特性:当穷尽的关键词集合没有返回任何命中时,系统可以以精确、可审计的理由返回「未找到」。
在企业场景(法律、合规、金融)中,这一区别至关重要:「我不确定」(模糊表述)对于合规人员来说毫无用处,而「这些特定术语都没有出现在文档中,如果答案存在,它们应该会出现」(可辩护的搜索结果)才是真正需要的答案。这也正是为什么在查询解析阶段构建专业词典是值得的投资:覆盖主题的词典不仅可以在答案存在时帮助找到答案,还可以让系统在答案不存在时证明这一点。
class RetrievalResult(BaseModel):
candidates: list[dict]
methods_used: list[str]
not_found: bool
not_found_reason: str | None = None
confidence: float
当not_found为True时,生成阶段就会知道应该返回「在可用文档中未找到答案」并附上理由,而非猜测答案。生成模块会在后续章节详细讨论这一点。
class RetrievalResult(BaseModel):
candidates: list[dict]
methods_used: list[str]
not_found: bool
not_found_reason: str | None = None
confidence: float
# 一个答案不在该论文中的读者问题:GDPR合规与Transformer架构无关。以下关键词具有高度辨识度,经验证在论文中无匹配
absent_question = "What does this paper say about GDPR compliance and personal data protection?"
absent_keywords = ["gdpr", "personal data", "compliance", "data protection officer"]
# 关键词侧:穷尽搜索返回0命中 -> 可辩护的「未找到」
title_hits = match_titles(toc_df, absent_keywords)
content_hits = line_df[line_df["text"].str.lower().apply(
lambda t: any(kw in t for kw in absent_keywords)
)]
keyword_envelope = RetrievalResult(
candidates=[],
methods_used=["title_keyword_match", "content_keyword_match"],
not_found=title_hits.empty and content_hits.empty,
not_found_reason="no_keyword_match" if title_hits.empty and content_hits.empty else None,
confidence=0.0 if title_hits.empty and content_hits.empty else 0.8,
)
print("KEYWORD result on absent topic:")
print(f" not_found = {keyword_envelope.not_found}, reason = {keyword_envelope.not_found_reason}")
# 嵌入侧:仍然返回top-k非零相似度的结果。没有阈值可以告诉我们「不在文档中」
emb_hits, _ = retrieve_pages_by_similarity(page_df, line_df, absent_question, top_k=3, client=client)
print(f"\nEMBEDDING result on the same absent topic:")
for _, r in emb_hits.iterrows():
print(f" page {int(r['page_num']):>2} sim={r['similarity']:.3f} (would be returned as a candidate)")
print(" -> embedding cannot say 'not found': it always returns top-k with continuous scores.")
3.2 「未找到」比错误答案更有价值
在消费级聊天机器人中,错误答案只是令人烦恼;但在企业文档问答系统中,错误答案可能代价高昂。举几个典型的企业场景:
- 合规人员询问合同是否包含竞业禁止条款,系统基于错误的段落返回「是的,有效期3年」,合规人员在会议中依赖这一信息,但实际条款有效期为1年,基于错误信息做出的决策可能引发严重后果
- 财务团队询问供应商合同的责任限额,系统返回了供应商保险限额(不同章节的不同数字),导致谈判基于错误的假设,直到一个月后才发现错误
- 法律团队询问合同是否覆盖数据泄露事件,系统返回了一段泛泛提及数据的一般性损害段落,团队误以为存在覆盖范围,最终导致公司面临未投保的事件损失
在所有这些场景中,「未找到答案」都比「错误答案」更好。「未找到答案」会提示用户进行更深入的挖掘、咨询同事或直接阅读文档,而「带有自信语气的错误答案」会短路整个决策过程,营造出虚假的确定感。
能够明确说「我没有找到任何内容」的检索管道,比召回率更高但缺乏谦逊的系统更有价值。这不仅是技术偏好,更是企业的业务要求。
四、检索输出:可直接用于生成的统一JSON格式
本文所有的内容最终都指向一个目标:生成合适的候选结果,交付给生成阶段。检索方法、LLM仲裁器、调度器、「未找到」处理逻辑,所有这些环节最终都会收敛为一个结构化的对象。本小节将明确定义该对象。
每个(文档,查询)对都会生成一个RetrievalResult对象,生成模块会读取该对象并生成答案,无需再向检索阶段发起额外请求。后续的编排循环可以在生成需要更多上下文时扩展该对象的结构,但核心的契约形式保持不变。
4.1 每个候选的两个范围:锚点与上下文
根据前文Article 7A中「检索即过滤」的锚点与上下文框架,每个候选结果都同时包含这两部分。锚点是匹配的精确位置:特定页面上的窄行范围,以及它所属的章节。上下文是检索阶段决定传递给生成阶段的扩展窗口:一个段落、一个章节,或N行的窗口,同样带有页面范围的行范围。
为什么需要同时保留两者?生成阶段需要锚点来实现引文功能(在PDF中高亮匹配的行,将答案链接回精确的行位置),同时需要上下文来实现 grounding功能(阅读答案所在的周边段落或章节)。仅存储上下文会丢失精确的引文信息,仅存储锚点则会丢失让答案可解释的周边证据。
行业通用的约定是:上下文的范围必须包含锚点的范围。上下文的宽度由查询解析阶段决定,由两个正交的维度驱动:answer_shape(结果基数)和answer_type(值类型),加上StructuralHints中的answer_context(需要读取多少周边文本)。比如对于(single, amount)类型的查询且answer_context=line,上下文会是窄范围(锚点加上几行的安全边际);而对于(listing, text)类型的查询且answer_context=section,上下文则会是整个章节。
4.2 统一的Schema定义
class CandidateScope(BaseModel):
"""A range in line_df: page + line bounds, plus the section it belongs to."""
page_start: int
page_end: int
line_start: int
line_end: int
section_id: str | None = None
class RetrievedCandidate(BaseModel):
"""One passage retrieved as a possible answer source."""
candidate_id: str
anchor: CandidateScope # where the match landed (narrow)
context: CandidateScope # what gets passed to generation (extended)
snippet: str # short, for the LLM arbiter brief
text: str # full context text, for generation
methods: list[str] # which methods surfaced it
matched_keywords: list[str] # which keywords landed on the anchor
section_title: str | None = None
role: Literal["primary", "supporting", "tangential"] | None = None
reason: str | None = None # LLM arbiter justification
class RetrievalResult(BaseModel):
"""The complete retrieval output, handed to generation as a single JSON."""
doc_id: str
question: str
candidates: list[RetrievedCandidate] # ordered: primary first
methods_used: list[str]
not_found: bool
not_found_reason: str | None = None
confidence: float
顶部的doc_id和question让结果具备可复现性。持久化该对象后,你可以在后续重新运行生成阶段,使用相同的检索结果,无需再次支付检索的成本。这是审计追踪(第一节所述)、批量评估以及任何需要比较同一检索证据下两个生成提示的编排逻辑的基础。
4.3 测试文档上的实际JSON示例
我们使用第一节中演示LLM仲裁器时的同一个查询:「该论文使用了哪种位置编码?」。最终有两个主要候选结果保留下来,都锚定在「3.5 Positional Encoding」章节,上下文扩展至整个章节正文。
{
"doc_id": "1706.03762v7",
"question": "What positional encoding does the paper use?",
"candidates": [
{
"candidate_id": "p5_l12_l14",
"anchor": {
"page_start": 5,
"page_end": 5,
"line_start": 12,
"line_end": 14,
"section_id": "11"
},
"context": {
"page_start": 5,
"page_end": 6,
"line_start": 1,
"line_end": 40,
"section_id": "11"
},
"snippet": "We use sine and cosine functions of different frequencies...",
"text": "3.5 Positional Encoding [... full section body, 40 lines, elided for the print ...]",
"methods": [
"title_keyword_match",
"content_keyword_match"
],
"matched_keywords": [
"positional",
"encoding",
"sinusoidal"
],
"section_title": "3.5 Positional Encoding",
"role": "primary",
"reason": "Defines the sinusoidal positional encoding used by the paper."
},
{
"candidate_id": "p6_l4_l5",
"anchor": {
"page_start": 6,
"page_end": 6,
"line_start": 4,
"line_end": 5,
"section_id": "11"
},
"context": {
"page_start": 5,
"page_end": 6,
"line_start": 1,
"line_end": 40,
"section_id": "11"
},
"snippet": "We also experimented with learned positional embeddings...",
"text": "3.5 Positional Encoding [... same full section body as candidate 1, shared context ...]",
"methods": [
"title_keyword_match",
"content_keyword_match"
],
"matched_keywords": [
"positional",
"encoding",
"learned"
],
"section_title": "3.5 Positional Encoding",
"role": "primary",
"reason": "Mentions the learned variant the paper also tested."
}
],
"methods_used": [
"title_keyword_match",
"content_keyword_match",
"llm_arbiter"
],
"not_found": false,
"not_found_reason": null,
"confidence": 0.95
}
当你将检索构建为服务或批量任务时,这一工件会被存入数据库、进行版本管理并支持复现。这也是审计人员询问为什么某个段落被展示给用户时会查看的内容。
解析模块所采用的缓存约定同样适用:save_retrieved_pages(pdf_path, question, retrieved_pages_df)会将页面级结果(带有match_count、matched_keywords列)写入output/<subdir>/<stem>/questions/<question_slug>/retrieved_pages.xlsx。过滤后的行表不会被缓存,生成阶段会根据line_df和页面编号现场重新生成。持久化页面ID是低成本的默认选择,而持久化繁重的过滤后DataFrame会造成重复状态。这保持了每个模块边界的清晰:检索负责写入页面选择,生成负责读取,双方都无需额外依赖。
五、总结
检索并非简单的搜索,而是基于结构化表格的过滤工作。在解析阶段生成line_df和toc_df之后,每个检索方法都是从一个或两个表格中选取行的方式。锚点检测流程分为三个阶段:始终免费的line_df和toc_df上的关键词检测,可选的并行嵌入相似度,聚合为结构化单元(章节、页面、文本块),最后通过一次LLM仲裁器调用完成带理由的候选排序。关键词检索始终保持启用,因为它们可审计且能提供可辩护的「未找到」结果;嵌入检索是可选的,适用于词汇不匹配的场景;BM25在企业查询中的表现不如业务编码的关键词过滤。锚点范围要小(行、标题),上下文范围要适当扩展(段落、章节),将这两个粒度混淆是最常见的管道错误。
第八章节(生成)将承接本模块,作为系列的第四个砖块。在获取了正确粒度和数量的候选结果后,LLM仍有大量工作要做:提取答案、格式化输出、添加引文、在答案不存在时拒绝编造内容。这一环节需要采用受控执行的方式,而非自由形式的预测。
塔猴是一个专注于为用户提供系统学习、内容创作与商业连接的AIGC综合服务平台,致力于为每一位AI探索者打造理想的创作、成长家园。在塔猴,你不仅可以学习众多AIGC类实战课程,获得与时俱进的AIGC技能和视野,还有机会获得长期商业合作和接单机会!点击进入:https://www.tahou.com/
AI生成内容提示:本文由人工智能辅助创作,内容仅供参考,不代表平台观点。请注意核实信息的准确性,并理性判断。




