仅用于站内搜索,没有排版格式,具体信息请跳转上方微信公众号内链接
↑↑↑关注后\“星标\“kaggle竞赛宝典
kaggle竞赛宝典
作者:文章摘自ChallengeHub
RAG挑战赛冠军方案!
多路由器+动态知识库:RAG冠军方案的核心技术揭秘
源码地址:https ://github. com/IlyaRice/RAG-Challenge-2/tree/main
比赛的任务是基于公司年度报告构建一个问答系统。简单来说,比赛当天的流程如下:
我们会收到来自随机挑选公司的100份年度报告,并需要在2. 5小时内解析这些报告并构建一个数据库。这些报告是PDF格式,每份最长可达1000页。
然后,系统会生成100个随机问题(基于预设模板),我们的系统必须尽可能快速地回答这些问题。
所有问题都必须有确定的答案,例如:
是/否;
公司名称(某些情况下是多个公司名称);
领导职位头衔、推出的产品名称;
数值指标:营收、商店数量等。
每个答案都必须注明引用的页码作为证据,确保系统是真正从原文中得出答案,而不是输出虚假信息(hallucinate)。
获胜方案在基础步骤之外,还加入了两个路由器(router)和LLM重排序模块(LLMreranking)。
接下来,我们将深入探讨构建该系统的每一个步骤,我们在过程中遇到的波折与困难,以及在此过程中摸索出的最佳实践。
RAG(Retrieval-AugmentedGeneration,检索增强生成)是一种通过与任意大小的知识库结合,从而扩展大语言模型(LLM)能力的方法。
解析(Parsing):为知识库准备数据,包括收集文档、将其转换为文本格式,并清理无关的噪点信息。
数据摄取(Ingestion):创建并载入知识库。
检索(Retrieval):构建一个工具,根据用户查询查找并返回相关数据,通常在向量数据库中进行语义搜索。
回答(Answering):使用检索到的数据丰富用户的提示词(prompt),将其发送给LLM,并返回最终答案。
要开始填充任何数据库,首先必须将PDF文档转换为纯文本。PDF解析是一项远非易事的工作,其中充满了无数细微的难题:
保留表格结构;
保留关键的格式元素(例如标题和项目符号列表);
识别多栏文本;
处理图表、图片、公式、页眉/页脚等等。
遇到但没时间解决的有趣的PDF解析问题:
大型表格有时会旋转90度,导致解析器产生乱码或无法阅读的文本。
*图表部分由图片、部分由文本层组成。
有些文档存在字体编码问题:视觉上看文本是没问题的,但尝试复制或解析时会得到一堆乱七八糟的字符。
有意思的是:我们单独调查了这个问题,发现文本是可以解码的——它是一个凯撒密码(Caesarcipher),每个单词的ASCII位移量都不同。这让我们产生了许多疑問。如果有人故意加密了一份公开可查的公司报告以阻止复制——为什么?如果是在转换过程中字体出了问题——为什么偏偏是这种方式?
我们尝试了大约二十几种PDF解析器:
小众解析器;
知名的解析器;
基于前沿机器学习算法训练的解析器;
支持API访问的商业解析器。
我们可以肯定地说,目前没有任何解析器可以处理所有细微之处,并在不丢失部分重要信息的情况下,将PDF内容完全还原为文本。
在RAG挑战赛中表现最好的解析器是相对知名的Docling。有趣的是,它的开发方是比赛的协办方之一——IBM。
尽管Docling的结果非常优秀,但它缺乏一些基本能力。这些功能部分存在,但分散在不同的配置中,无法组合在一起。
因此,我们卷起袖子,彻底研究了库的源代码,并重写了几个方法以满足我们的需求,从而在解析后得到了一个包含所有必要元数据的JSON文件。利用这个JSON,我们构建了一个Markdown文档,其格式经过修正,并且表格结构从PDF转换为Markdown甚至HTML(这在后来证明非常有用!)的还原度接近完美。
这个库相当快,但仍然不够快到能在2. 5小时内在个人笔记本电脑上解析1.5万页文档。为了解决这个问题,我们利用了GPU加速解析,并在比赛期间租用了一台配备4090GPU的虚拟机,每小时花费70美分。
解析所有100份文档耗时约40分钟,根据其他参赛者的报告和评论,这是极高的解析速度。
至此,我们的报告已被解析为JSON格式。
现在我们可以填充数据库了吗?
还没。首先,我们必须清除文本中的噪点信息,并对表格进行预处理。
有时部分文本从PDF中解析出来时会出错,包含特定的语法,降低了可读性和意义。我们使用了一批十几个正则表达式来处理这个问题。
解析不佳的文本示例
之前提到的凯撒密码文档也通过正则表达式模式检测到了。我们试图解码它们,但即使修复后,它们仍然包含许多伪影(artifact)。因此,我们干脆直接对这些文档进行了完整的OCR识别。
在大型表格中,度量名称(横向表头)通常离纵向表头太远,削弱了语义连贯性。
有1500个不相关的token将纵向和横向表头隔开
这显著降低了在向量搜索中chunk的相关性(更不用说表格完全装不进一个chunk的情况了)。此外,LLM在处理大型表格时也很难将度量名称与表头对应起来,可能会返回错误的值。
序列化的核心是将一个大表格转化为一系列小的、上下文独立的字符串。
在经过大量的提示词(prompts)和结构化输出schema实验后,我们发现了一种解决方案,即使是GPT-4o-mini也能几乎无损地序列化大型表格。最初,我们以Markdown格式向LLM输入表格,但后来改用HTML格式(这就是它派上用场的地方!)。语言模型对HTML的理解程度要高得多,而且HTML可以描述包含合并单元格、子标题和其他复杂结构的表格。
要回答诸如“这家公司2021年的股东权益是多少?”之类的问题,只需向LLM输入一个句子,而非一个包含许多“噪点”的大型结构,这就足够了。
在序列化过程中,整个表格被转换成这样一组独立的块:
subject_core_entity:Shareholders’equity(股东权益)
information_block:截至2012/3至2022/3年的股东权益如下:637,422百万日元(2012/3),535,422百万日元(2013/3),679,160百万日元(2014/3),782,556百万日元(2015/3),540,951百万日元(2016/3),571,983百万日元(2017/3),511,242百万日元(2018/3),525,064百万日元(2019/3),513,335百万日元(2020/3),577,782百万日元(2021/3),以及1,274,570百万日元(2022/3)。
获得表格的序列化版本后,我们将其放在原表格下方,作为每一个元素的文本标注。
我们可以在项目的仓库中查看序列化的提示词和逻辑:tables_serialization. py
*尽管序列化潜力巨大,但获胜的解决方案最终并未采用它。我们将在文章末尾解释原因。
报告已从PDF转换为干净的Markdown文本。现在让我们用它们来创建数据库。
在搜索系统領域(如GoogleSearch、全文搜索、ElasticSearch、向量搜索等),一个文档(document)是系统返回的单一索引元素作为查询结果。一个文档可以是一个句子、段落、页面、网站、图片——不重要。但就个人而言,这个定义总是让我们困惑,因为它有一个更常用、更日常的含义:文档(document)作为一份报告、合同或证书。
因此,从现在起,我们将使用文档在其日常含义上。
存储在数据库中的元素,我们称之为块(chunk),因为我们存储的只是被切分过的文本片段。
根据比赛规则,我们必须指明包含相关信息的页码。企业系统也采用同样的方法:引用允许验证模型的答案是否是虚假信息(hallucinated)。
这不仅使系统对用户更透明,也简化了开发过程中的调试。
最简单的选择是将文档的整页作为一个块,因为页面很少超过几千个token。
但让我们再次思考查询和文档文本块之间的语义连贯性。通常情况下,足以回答问题的信息片段不超过十个句子。
因此,从逻辑上讲,一个包含目标语句的小段落将比同样语句稀释在一整页相关性较弱的文本中获得更高的相似度得分。
我们将每页的文本分割成300个token(约15个句子)的块。
为了切分文本,我们使用了带有自定义Markdown词典的递归拆分器。为了避免信息在两个块之间被切断而丢失,我们添加了一个小的文本重叠(50个token)。如果我们担心重叠不能完全消除切分不当带来的风险,可以搜索一下“语义拆分器(Semanticsplitter)”。如果我们计划只将找到的块插入到上下文中,这一点尤其重要。
然而,切分的精确度对我们的检索系统几乎没有影响。
每个块都存储其ID及其在元数据(metadata)中的父页面编号。
我们的块集合已准备就绪;现在让我们创建向量数据库——或者更确切地说,是多个数据库。100个数据库,其中1个数据库=1个文档。
因为为什么要将所有公司的信息混合在一起,之后再试图将一个公司的收入与另一个公司分开呢?答案的目标信息总是严格限制在单个文档内。
我们只需根据给定的问题确定要查询哪个数据库(稍后会详细介绍)。
为了创建、存储和搜索向量数据库,我们使用了FAISS。
数据库使用IndexFlatIP方法创建。
Flat索引的优点是所有向量都“原样”存储,没有压缩或量化。搜索使用暴力搜索,精度更高。缺点是这种搜索计算和内存消耗显著更高。
如果我们的数据库至少有十万个元素,可以考虑IVFFlat或HNSW。这些格式速度快得多(尽管创建数据库时需要稍多一些资源)。但速度的提升是以牺牲精度为代价的,因为它使用的是近似最近邻搜索(ANN)。
将所有文档的块分离到不同的索引中,我们得以使用Flat数据库。
IP(内积)用于通过余弦相似度计算相关性得分。除了IP,还有L2——它通过欧氏距离计算相关性得分。IP通常能提供更好的相关性评分。
为了将块和查询嵌入到向量表示中,我们使用了text-embedding-3-large。
创建好数据库后,是时候进入RAG系统的“R”(检索)部分了。
检索器(Retriever)是一个通用的搜索系统,它接收查询作为输入,并返回包含回答所需信息的相关文本。
在基础实现中,它只是对向量数据库发起查询,提取TopN个结果。
这是RAG系统中尤其关键的部分:如果LLM在查询上下文中没有接收到必要信息,它就无法提供正确答案——无论我们如何精心微调解析或回答提示词。
\“垃圾进→垃圾出(Junkin→Junkout)。\“
提高检索器质量的方法有很多。以下是我们在比赛期间探索过的方法:
vDB+BM25混合搜索(Hybridsearch)结合了基于向量的语义搜索和传统的基于关键词的文本搜索(BM25算法)。理论上,它通过不仅考虑文本含义,还考虑精确关键词匹配来提高检索准确性。通常,两种方法的搜索结果会合并并根据综合得分进行重排序。
我们并不是特别喜欢这种方法:在它的基础实现中,它通常会降低检索质量而不是提高。
总的来说,混合搜索是一個不错的技巧,通过修改输入查询可以進一步优化。最简单地说,LLM可以重述问题以去除噪点并增加关键词密度。
如果我们在混合搜索方面有积极的经验,特别是关于潜在问题和解决方案,请在评论区分享。
无论如何,我们心中有更具前景的替代方案,于是決定不再进一步探索这个方向。
使用交叉编码器模型(Cross-encodermodels)对向量搜索结果进行重排序看起来很有前景。简单来说,交叉编码器能给出更精确的相似度分數,但速度较慢。
交叉编码器介于嵌入模型(例如双编码器bi-encoder)和LLM之间。与通过文本的向量表示(这inherently会丢失一些信息)进行比较不同,交叉编码器直接评估两个文本之间的语义相似度,从而给出更准确的分数。
然而,对查询与数据库中每个元素的成对比较耗时太长。
因此,交叉编码器重排序仅适用于向量编码器过滤出一小部分块。
在比赛的最后一刻,我们放弃了这种方法,原因是通过API提供的交叉编码器重排序模型稀缺。无论是OpenAI还是其他大型提供商都没有提供,而我们不想为了管理另一个API余额而麻烦。
但如果我们有兴趣尝试交叉编码器重排序,我们推荐JinaReranker。它在基准测试中表现出色,而且注册后Jina会提供慷慨的免费请求额度。
最终,我们选择了更具吸引力的替代方案:LLM重排序!
这很简单:将文本和问题传递给LLM,并问:“这段文本有助于回答问题吗?有多大帮助?它的相关性评分从0到1是多少?”
直到最近,由于强大的LLM模型成本高昂,这种方法还不可行。但现在我们有了快速、廉价且足够智能的LLM模型。
和交叉编码器重排序一样,我们也在通过向量搜索初步过滤后应用此方法。
我们开发了一个详细的提示词,描述了通用指导和精确到0. 1的显式相关性标准:
0=完全不相关:文本块与查询没有任何联系或关联。
0. 1=基本不相关:仅与查询有非常轻微或模糊的关联。
0. 2=非常微弱相关:包含极其微小或切线式的关联。
…
LLM查询被格式化为结构化输出(Structuredoutput),包含两个字段:reasoning(让模型解释其判断)和relevance_score,可以直接从JSON中提取,无需额外解析。
我们进一步优化了流程,在一个请求中同时发送三页内容,提示LLM同时返回三个页面的分数。这提高了速度,降低了成本,并因相邻文本块能为模型的评估提供更多上下文而略微提高了评分的一致性。
修正后的相关性得分使用加权平均计算:
vector_weight=0. 3,llm_weight=0. 7理论上,我们可以直接跳过向量搜索,将每一页都直接传递给LLM。有些参赛者就这样做了,而且取得了成功。然而,我们相信使用嵌入(embedding)进行更便宜、更快速的过滤仍然是必要的。对于一份1000页的文档(有些文档就是这么大),仅仅回答一个问题就可能花费约25美分——太昂贵了。
而且,归根结底,我们是在参加一场RAG挑战赛,不是吗?
通过GPT-4o-mini进行重排序的成本对我们来说每个问题不到一美分!这种方法在质量、速度和成本之间取得了出色的平衡——这正是我们选择它的原因。
我们可以在这里查看重排序提示词:prompts. py
还记得我们之前提到将文本分割成更小的块吗?好吧,这里有一个小但重要的注意事项。
是的,回答问题的核心信息通常集中在一个小的块中——这正是将文本分解成小块能提高检索质量的原因。
但该页上剩余的文本可能仍然包含次要的——但仍然重要——的细节。
因此,在找到TopN个相关块后,我们只将它们用作指向整个页面的指针;然后将完整的页面添加到上下文(context)中。这正是我们在每个块的元数据中记录页面编号的原因。
让我们回顾一下最终检索器的步骤:
对查询进行向量化。
根据查询向量找到Top30个相关块。3. 通过块的元数据提取对应的页面(记得去重!)。
通过LLM重排序器处理这些页面。
调整页面的相关性得分。
返回得分最高的Top10页,在每一页前面加上页码,并将它们合并成一个字符串。
我们的检索器现在准备就绪了!
我们的向量数据库已建立,检索器已准备好。RAG系统中的“R”(检索)部分至此已完成,我们现在进入“A”(增强)部分,这部分相当直接,主要包括f-string和字符串拼接操作。
一个有趣的地方是我们组织提示词存储的方式。在多个项目中尝试了不同的方法后,我们最终确定了以下方法:
我们将提示词存储在一个专门的prompts. py文件中,通常将提示词分割成逻辑块:
核心系统指令;
定义LLM返回响应格式的Pydanticschema;
用于创建单次示例(one-shot)/少次示例(few-shot)提示词的问答对示例;
用于插入上下文和查询的模板。
一个小型函数会根据需要将这些块组合成最终的提示词配置。这种方法允许灵活测试不同的提示词配置(例如,比较不同示例对单次示例提示词的有效性)。
某些指令可能会在多个提示词中重复出现。以前,修改这类指令意味着需要在所有使用它们的提示词中同步更新,这很容易导致错误。模块化方法解决了这个问题。现在,我们将重复出现的指令放入一个共享块中,并在多个提示词中重用。此外,当提示词变得过长时,模块化块也简化了处理。
所有提示词都可以在项目仓库中查看:prompts. py
RAG中的第三个部分“G”(生成)是最耗费精力的。要在这一阶段实现高质量,需要巧妙地应用几种基本技术。
这是RAG系统中最简单却最有用的部分之一。
回想一下,每份报告都有其独立的向量数据库。问题生成器设计成公司名称始终明确出现在问题中。
我们也有所有公司名称的列表(比赛开始时与PDF报告一同提供)。因此,从查询中提取公司名称甚至不需要LLM:我们只需遍历列表,通过re. search()从问题中提取名称,并将其与相应的数据库匹配。
在实际应用场景中,将查询路由到数据库比在我们受控、理想的环境中更复杂。我们很可能还会需要额外的预处理任务:为数据库打标签,或者使用LLM从问题中提取实体,然后将其与数据库匹配。但在概念上,方法保持不变。
总结一下:
找到公司名称→匹配到相应的向量数据库→只在该数据库中搜索。搜索空间缩小了100倍。
比赛的一个要求是回答的格式。每个答案必须简洁,并严格符合数据类型,就像直接将其存入公司数据库一样。
每个问题旁边都明確給出了预期的数据类型——int/float、bool、str或list[ str]。
每种类型都涉及3到6个需要在回答时考虑的细微之处。
例如,如果问题询问某个指标的数值,答案必须是纯数字,不包含评论、货币符号等。对于金钱指标,报告中的货币必须与问题中提到的货币一致,并且数字必须规范化——例如报告中常写“$1352(inthousands)”,系统必须回覆“1352000”。
如何确保LLM同时考虑所有这些细微之处而不出错?简单地说:我们做不到。给LLM的规则越多,它忽视它们的可能性就越高。即使只有八条规则,对当前的LLM来说也多得危险。模型的认知能力是有限的,额外的规则会分散其回答核心问题的注意力。
这逻辑上得出了一个结论:我们应该尽量减少每个查询的规则数量。一个方法是将一个查询分解为一系列更简单的查詢。
然而,在我们的案例中,由于明确提供了预期的响应类型,我们可以实现一个更简单的解决方案——根据不同的答案类型,我们們只向提示詞提供相应的指令集。
我们编写了四种提示词变体,並使用简单的ifelse语句选择正确的那个。
比赛还包括比较多家公司指标的问题。这类问题与那些更简单的查询不符,因为它们需要额外的步骤来回答。
问题示例:
苹果和微软谁的营收更高?
我们想想:人会如何處理这个任务?
首先,他们會分别查找每家公司的营收,然后进行比较。
我们将同样的思维嵌入到我们的系统中。
我们将最初的比较问题传递给LLM,并要求它创建更简单的子问题,以分别提取每家公司的指标。
在我们的例子中,更简单的子问题将是:
苹果的营收是多少?和微软的营收是多少?
现在我们可以通过标准流程分别处理每家公司的这些更简单的查询。收集到每家公司的答案后,我们将它们作为上下文传递给LLM,以回答最初的问题。
这种模式适用于任何复杂的查询。关键在于识别它们并确定所需的子步骤。
思维链(CoT)通过让模型在给出最终答案之前“出声思考”,显著提高了答案质量。LLM不会立即给出答案,而是生成一系列导致解决方案的中间推理步骤。
就像人类一样,LLM在将复杂問題分解為更小、更簡單的問題時,處理得更好。CoT幫助模型避免遗漏关键细节,有条不紊地處理信息,并得出正确结论。在上下文包含可能误导模型的“陷阱”时,它尤其有用。
我们无疑听说过那句标志性的话:一步步思考(Thinkstepbystep)。这是最早试图通过提示词(prompting)来提高答案质量的尝试之一。它实际上催生了奇特的“提示词工程(promptengineering)”。然而,对于严肃的任务,这种通用指令是不够的。
LLM有时会“虚假推理(fakereasoning)”——例如,先给出即时答案,然后倒过来找理由解释,或者编造不存在的事实。这个问题在像GPT-4o-mini或Llama8b这样较弱的模型中尤为常见。
为了确保CoT有益,我们必须清晰地引导模型如何进行推理。解釋推理步驟、目標,并提供示例。
根据提示词指令明确地构建推理步骤,可以显著增强规则的遵循。
在RAG中,当报告中缺乏完全符合要求的指标时,上下文可能包含相似但不完全相同的信息。模型可能會試圖扭曲提供的数据以符合请求的指标。由于模型被训练得乐于助人,它们偏向于提供某个答案而不是承认不知道。
仅仅要求模型保持怀疑态度来对抗这种偏见确实有效——但并非始终如一,也并非在所有情况下都有效。相反,明确提示模型从不同角度或视角分析信息会更有帮助。
我们特別指示模型重点评估问题和上下文之间指标的兼容性。这显著减少了虚假信息的生成。
问题:
RitterPharmaceuticalsInc. 的研发设备(按成本计)是多少?
模型的推理步骤:
问题询问RitterPharmaceuticalsInc. 的“研发设备,按成本计”。这表明需要从资产负债表中提取一个特定数值,代表专门用于研发的设备的原始购置成本,不包含任何累计折旧。
上下文(第35页)显示“不动产和设备,净额”为12,500美元。这是一个净值(已扣除折旧),而且是一个更广泛的类别,包含所有不动产和设备,而不仅仅是研发设备。
上下文(第37页)还提到“机械和设备”的“累计折旧”为110,000美元。这代表了总的折旧,而不是原始成本,更重要的是,它没有说明这些设备专门用于研发。
这两个指标都不完全符合请求的指标。“不动产和设备,净额”范围太广,且是折旧后的价值。“累计折旧”仅显示折旧,而非成本,并且缺乏研发的针对性。5. 由于上下文没有提供仅用于研发设备的原始成本,且我们無法進行假設、計算或組合信息,因此答案是“N/A”。
结构化输出(StructuredOutput,SO)是一种强制模型以严格定义的格式响应的方式。它通常作为一个单独的参数传递给API,例如一个Pydantic或JSONschema(模式)。
这保证了模型总是返回符合所提供schema的有效JSON数据。
字段描述也可以包含在响应schema中。这些不影响结构,但会被LLM视为提示词的一部分来处理。
例如,以下是用于LLM重排序的Pydanticschema:
有了这个schema,LLM总是会返回一个包含两个字段的JSON——第一个是字符串,第二个是数字。
上述方法可以理想地结合使用。
在生成阶段,模型有一个专门用于推理的字段,还有一个单独的字段用于最终答案。这使得我们可以直接提取答案,而无需从冗长的推理步骤中进行解析。
可以在结构化输出中以多种方式实现思维链。例如,您可以使用多个JSON字段,每个字段引导模型得出中间结论,这些结论的组合最终引导它得出正确的最终答案。
然而,由于回答比赛问题所需的逻辑无法通过预定义的一组分步指令来描述,我们采用了更通用的方法,为模型提供一个单一的推理字段,并直接在提示词中定义推理序列。
在我们用于回答比赛问题的主要schema中,只有四个字段:
step_by_step_analysis——初步推理(思维链本身)。
reasoning_summary——前一个字段的精简摘要(用于更轻松地跟踪模型的逻辑)。
relevant_pages——答案引用的报告页码。
final_answer——根据比赛要求格式化后的简洁答案。
前三个字段在为不同答案类型量身定制的四种提示词中被重用。第四个字段每次都有所不同,指定答案类型并描述模型必須Consideration的具體細微之處。
例如,确保final_answer字段始终是数字或“N/A”是这样实现的:
final_answer:Union[float,int,Literal[‘N/A’]]
并非所有LLM都支持结构化输出,因为只有它才能保证完全遵守schema。
如果一个模型没有专门的结构化输出功能,我们仍然可以直接在提示词中呈现输出schema。在大多数情况下,模型通常足够智能,可以返回有效的JSON。然而,部分答案不可避免地会偏离schema,导致代码出错。小型模型尤其如此,大约一半的时间都无法符合要求。
为了解决这个问题,我们编写了一个回退方法,它使用schema. model_validate(answer)来验证模型的响应是否符合schema。如果验证失败,该方法会将响应重新发送给LLM,提示它遵守schema。
这种方法将schema合规率提高到100%,即使对于8b模型也是如此。
这是提示词本身。
这是另一种常见且相当显而易见的方法:在提示词中添加一个问答示例对,可以提高响应质量和一致性。
我们在每个提示词中都添加了一对“问题→答案”,答案以结构化输出定义的JSON格式编写。
这个示例同时服务于多种目的:
展示一个示例性的分步推理过程。
进一步阐明在困难情况下的正确行为(帮助重新校准模型的偏见)。
演示模型回答应该遵循的JSON结构(尤其对缺乏原生SO支持的模型有用)。
我们非常重视精心编写这些示例答案。提示词中示例的质量可以提升或降低响应质量,因此每个示例必须与指令完全一致,并且总体上几乎无懈可击。如果示例答案与指令矛盾,模型就会变得困惑,这会对其表现产生负面影响。
我们仔细地修改了示例中的分步推理字段,手动调整了推理结构和每个短语的措辞。
这一部分的工作量与整个数据准备阶段不相上下,因为需要进行无止境的迭代调试、校对答案以及手动分析模型的推理过程。
在编写提示词之前,我们徹底研究了响应要求和问题生成器。
构建一个以LLM为核心的优秀系统的关键在于理解客户需求。通常,这需要深入的专业领域知識和对问题的细致检查。我们坚信,除非我们清晰地理解问题本身以及如何找到答案,否则不可能为企业构建真正高质量的问答系统(如果有人能说服我们,我们会很高兴)。
这种理解也是澄清用户问题中所有隐含意义所必需的。
让我们考虑问题示例:ACMEinc的CEO是谁?
在一个理想的世界里,报告总是会明确地提供答案,不留误解的余地:
现由JohnDoe负责CEO职责
RAG系统会在报告中找到这句话,将其添加到查询上下文,用户会收到一个明确的答案:JohnDoe
然而,我们生活在现实世界中,成千上万的公司以各种方式表达信息,伴随着无数额外的细微之处。
这就引出了一个问题:到底什么可以归入“CEO”这个称谓之下?
系统应该多么字面地理解客户的问题?
客户是想知道担任类似管理角色的人的名字,还是严格来说就是那个特定的职位头衔?
稍微偏离字面解释是否可以接受?偏离多远算太远?
潜在地,以下职位可能被包括在内:
首席执行官(ChiefExecutiveOfficer)——显然就是CEO的全称。
我们不确定是否存在对此已有的术语,但我们个人将其称为“解释自由度阈值”问题。
当响应是自由格式时,解释自由度阈值相对容易解决。在模棱两可的情况下,LLM会尝试涵盖用户查询中所有隐含的含义,并添加一些澄清。
这是一个真实示例的ChatGPT回复:
根据提供的上下文,EthanCaldwell是董事总经理,这是该公司中最接近CEO的职位。但是,由于正在进行的监管调查,他已被正式暂停了积极的行政职责。虽然他保留了头衔,但目前不参与公司运营,领导权已暂时移交给董事会监督下的高级管理团队。
然而,如果系统架构要求简洁的答案,就像RAG挑战赛这样,模型在这些情况下表现就不稳定了,依赖其内部的“直觉”。
因此,解释自由度阈值必须提前定义和校准。但由于无法明确定义和量化这个阈值,必须识别所有主要的边缘情况,制定通用的查询解释规则,并与客户澄清模糊之处。
除了理解问题外,还可能出现一些普遍的困境。
例如:ACMEinc是否宣布了股息政策的任何变更?
系统是否应该将报告中缺乏信息解读为没有宣布任何变更?
Rinat(比赛组织者)可以证实——在比赛准备期间,我们用几十个类似的问题和困境轰炸了他:)
比赛开始前一周,问题生成器的代码公开了。मैंतुरंत生成了一百个问题,并从中创建了一个验证集。
手动回答问题相当繁琐,但这在两个关键领域帮助了我们:
当我们进行改进时,验证集可以客观地衡量系统的质量。通过在该集合上运行系统,我们可以监控它正确回答了多少问题以及最常在哪里出错。这种反馈循环有助于迭代改进提示词和其他流水线组件。
手动分析问题突出了问题和报告中不明显的细节和歧义。这让我们能与Rinat核实回答要求,并将这些规则明确地反映在提示词中。
我们将所有这些澄清纳入提示词中作为指令集。
答案类型=数字
如果提供的指标与问题中提到的货币不同,则返回“N/A”。如果上下文没有直接说明指标(即使可以从上下文中的其他指标計算得出),则返回“N/A”。特别注意上下文是否提到指标是以单位、千或百万为单位报告的,以便在最終答案中相应地调整数字,不作任何更改、添加三个零或六个零。注意值是否用括号括起来;这意味着该值为负数。
答案类型=名称
如果问题询问职位(例如职位的变动),则仅返回职位头衔,不包括姓名或任何其他信息。任命新的领导职位也應算作职位的变动。如果提到与同一头衔的职位相关的几次变动,只返回该职位头衔一次。职位头衔始终应使用单数形式。
如果问题询问新推出的产品,则仅返回上下文中的产品名称,完全按照原文。新产品候选者或测试阶段的产品不应计为新推出的产品。
模型很容易遵循某些指令,由于固有的偏见而抗拒其他指令,并且在某些指令上遇到了困难,导致错误。例如,模型在跟踪计量单位(千、百万)时反复出错,忘记在最终答案中添加必要的零。于是,我们在指令中补充了一个简短的示例:
千为单位的数字示例:
上下文中的值:4970,5(inthousands$)
最终答案:4970500
最终,我们为每种问题格式编写了提示词,并编写了几个辅助提示词:
数字类型问题最终提示词
名称类型问题最终提示词
名称列表类型问题最终提示词
布尔类型问题最终提示词
比较类型问题最终提示词(用于通过多查询路由比较多个公司的答案)
比较类型问题的复述提示词(用于初步在报告中查找指标)
LLM重排序提示词
SO重解析器提示词对指令的细致优化结合单次示例和结构化输出思维链,带来了显著的好处。最终的提示词彻底改变了系统中不必要的偏见,并极大地提高了对细微之处的关注度,即使对于较弱的模型也是如此。
最初,RAG挑战赛的规则更严格,要求系统在10分钟内回答所有100个问题,才有资格获得奖金。我们认真对待这项要求,并力求充分利用OpenAI的每分钟token限制。
即使在Tier2,限额也很慷慨——GPT-4o-mini每分钟2百万token,GPT-4o每分钟45万token。我们估算了每个问题的token消耗量,并分批处理25个问题。系统仅用了2分钟就完成了所有100个问题。
最后,提交解决方案的时间被大大延长了——其他参赛者根本无法及时完成:)
拥有一个验证集不仅帮助改进了提示词,也使整个系统受益。
我们将所有关键功能配置化,以便衡量它们的实际效果并微调超参数。以下是一些示例配置字段:
在测试不同配置时,我们惊讶地发现我们对它寄予厚望的表格序列化功能不仅没有改进系统,反而略微降低了其有效性。显然,Docling从PDF解析表格的能力足够好,检索器也能有效地找到它们,而LLM在没有额外帮助的情况下也能充分理解它们的结构。而向页面添加更多文本只会降低信噪比。
我们还为比赛准备了多种配置,以便在所有类别中快速运行各种系统。
最终系统在使用开源模型和商业模型时都表现出色:Llama3. 370b的得分仅比OpenAI的o3-mini低几个点。even微小的Llama8b在总榜上都超过了80%的参赛者。
最终,赢得RAG挑战赛并非因为找到了某个神奇的解决方案,而是因为采取了系统化的方法,深思熟虑地结合和微调了各种技术,并深入钻研了任务细节。关键的成功因素包括高质量的解析、高效的检索、智能的路由,以及——最值得一提的——LLM重排序和精心设计的提示词,这些使得即使使用紧凑的模型也能取得出色的结果。
这场比赛的主要启示很简单:RAG的魔力在于细节。我们越了解任务,就能越精确地微调每个流水线组件,从最简单的技术中也能获得更大的收益。
作者已将所有系统代码开源。其中包括如何自行部署系统和运行流水线任意阶段的说明。