一次RAG竞赛的实战经验分享(旧文)

发布于:2026-4-26|最后更新: 2026-4-28|
Created time
Apr 27, 2026 05:45 AM
category
library
date
Apr 26, 2026
status
Published
icon
password
slug
for-rag-competition-experience
type
post
likes
views
summary
一次RAG竞赛实战分享,详述从PDF解析、数据清洗、向量检索、LLM重排到系统化提示词设计的全流程经验与关键技巧。
tags
工程实践
实用教程
RAG
原文:abdullinabdullinIlya Rice: How I Won the Enterprise RAG Challenge
发布时间:2025 年 3 月 25 日
一篇老文了,最近遇到一些RAG的问题,回看了一下,觉得还是有启发,因此翻译分享一下。

从零到 SOTA:在一场竞赛里把系统做到顶

这场 RAG Challenge 到底比什么?

这次比赛的任务是:基于公司年度报告(Annual Reports)搭建一个问答系统。比赛当天流程大致如下:
  1. 主办方会随机挑选公司,给你 100 份年度报告,并给你 2.5 小时把它们解析并构建成可检索的数据库。报告都是 PDF,每份可能长达 1000 页
  1. 然后系统会根据预定义模板生成 100 个随机问题,你的系统需要尽可能快地给出答案。
这些问题都必须是“有确定答案”的类型,例如:
  • 是/否(Yes/No)
  • 公司名称(某些情况下可能是多个公司)
  • 领导岗位名称、产品名称
  • 数值指标:营收、门店数量等
更重要的是:每个答案必须附带证据页引用(指出报告中对应的页码),从而证明答案确实来自文档而不是模型“编造”。

获胜系统的总体架构

 
notion image
除了一条“常规 RAG 流水线”之外,冠军方案还引入了 两层 Router(路由器)LLM 进行重排(Reranking)。 你可以在这里查看我表现最好的系统输出的问答结果:answers_1st_place_o3-mini.json
接下来我会按步骤拆解整个系统:从构建过程中的坑与代价,到最终沉淀的最佳实践。

RAG 速览:一个基础系统通常分 4 段

RAG(Retrieval-Augmented Generation,检索增强生成)是一类方法:通过把大型语言模型(LLM)与任意规模的知识库结合,来扩展其回答能力。
一个最基础的 RAG 系统通常包含以下四个阶段:
  1. Parsing(解析):收集文档,把 PDF 等格式转成可处理的文本,并清理噪声
  1. Ingestion(导入):把清洗后的内容写入知识库/索引
  1. Retrieval(检索):根据问题从知识库中找回相关内容(通常用向量检索)
  1. Answering(回答):把检索结果拼入提示词,让 LLM 生成最终答案

1. Parsing:解析 PDF 的“地狱级”难度

要往数据库里灌任何内容,第一步都是把 PDF 转成纯文本。但现实是:PDF 解析并不简单,里面有无数细节坑,例如:
  • 怎么保留表格结构
  • 怎么保留关键格式(标题、列表等)
  • 怎么处理多栏文本
  • 怎么处理图表、图片、公式、页眉/页脚等

我遇到但没时间彻底解决的有趣问题

  • 有些大表格会整体旋转 90 度,导致解析出来全是乱码、不可读
 
notion image
  • 有些图表一部分是图片,一部分是文本层(解析器很容易混乱)
  • 有的文档存在字体编码问题:肉眼看正常,但复制/解析得到一串无意义字符
 
notion image
额外彩蛋:我单独研究过这种编码问题,发现它其实可解——更像是“凯撒加密”,而且每个单词的 ASCII 位移还不一样。于是我开始好奇:如果真有人故意把公开年报的可复制文本加密——为什么?如果是转换过程出了错——又为什么偏偏会变成这种形式?

选择解析器:没有“万能选手”

我大概试了 二十多个 PDF 解析器,包括:
  • 小众解析器
  • 业内口碑不错的解析器
  • 最新的 ML 训练型解析器
  • 提供 API 的闭源商用解析器
结论很明确:到目前为止,没有任何一个解析器能覆盖所有细节,并在不丢失关键信息的情况下把 PDF 完整还原为高质量文本。
这次比赛里表现最好的解析器,是相对知名的 Docling。有意思的是,它背后正是比赛主办方之一 IBM

解析器改造:把缺的能力补齐

Docling 的效果很好,但仍缺少一些关键能力。更麻烦的是:这些能力虽然“局部存在”,却散落在不同配置里,无法合在一次运行里同时启用。
于是我直接读源码,重写了几个核心方法,让解析输出变成一个包含全部必要元数据的 JSON。然后我再用这个 JSON 去生成 Markdown,并且把表格结构做了纠正,最终能做到:表格不仅转成 MD,还能转成 HTML(这在后续很关键)。
Docling 本身速度很快,但要在个人笔记本上把 15000 页2.5 小时内解析完仍不够。解决方案是:用 GPU 加速解析,并在比赛时租了一台带 4090 的云主机——成本大概 0.7 美元/小时。Runpod 在短租 GPU 上非常方便。
最终,解析 100 份文档耗时约 40 分钟。根据其他选手的反馈,这已经是非常高的解析速度了。

到此为止,我们得到的是“解析后的 JSON”。
那能直接写入数据库了吗?还不行:接下来还得降噪,并对表格做预处理。

文本清洗与表格准备

有时 PDF 解析会输出一些特殊语法/碎片文本,严重影响可读性和语义。我用了一组大约十来条正则表达式,批量清理掉这些噪声。
坏解析文本示例:
notion image
对于前面提到的“凯撒加密式”文本,我也用 regex 模式识别出来,尝试过解码,但还原后仍然有大量伪影。最后我选择对这类文档直接全量走 OCR。

表格序列化(Table Serialization)

大型表格常见的问题是:横向表头(指标名)与纵向表头(维度/年份等)之间隔得很远,语义关联被稀释。
比如:纵向表头与横向表头之间可能隔着 1500 个无关 token
notion image
这会显著降低向量检索时 chunk 的相关度(更别说表格经常被切成多块)。同时,大表格也让 LLM 更难正确把指标名与表头对应起来,容易读错数。
解决思路是:序列化——把大表格转成一组“上下文自洽”的小字符串。关于这块的公开研究并不多,我基本是自己摸索。你可以搜索:
  • Row-wise Serialization
  • Attribute-Value Pairing
或直接看这篇论文:arXiv:2402.17944
序列化的本质:把表格变成很多独立文本块。经过大量 prompt 与结构化输出(Structured Output)实验,我找到一种方法:即使是 GPT-4o-mini 也能几乎无损地完成超大表格的序列化。
我最初把表格用 Markdown 交给模型,但后来改用 HTML(前面说的“表格转 HTML”就派上用场了):模型对 HTML 的理解更好,而且能更好表达合并单元格、子标题等复杂结构。
例如,要回答“公司 2021 年股东权益是多少?”这种问题,只需要喂给模型一句话,而不是塞一整页表格噪声。
序列化后会生成类似这样的块:
  • subject_core_entity: Shareholders' equity
  • information_block: Shareholders' equity for the years from 2012/3 to 2022/3 are as follows: ¥637,422 million (2012/3), ¥535,422 million (2013/3), ¥679,160 million (2014/3), ¥782,556 million (2015/3), ¥540,951 million (2016/3), ¥571,983 million (2017/3), ¥511,242 million (2018/3), ¥525,064 million (2019/3), ¥513,335 million (2020/3), ¥577,782 million (2021/3), and ¥1,274,570 million (2022/3).
拿到序列化文本后,我会把它放在原表格下面,作为一种“文本注释”。
相关 prompt 与实现代码在仓库里:tables_serialization.py
*尽管序列化看起来潜力巨大,但最终冠军方案并没有在最终系统中使用它。原因我会在文章末尾解释。

2. Ingestion:把文档写进数据库之前,先统一术语

到这里,报告已经从 PDF 转成干净的 Markdown 文本。下一步是创建数据库并把内容导入。
在搜索系统领域(Google Search、全文检索、Elastic、向量检索等),“document”指的是搜索系统返回的最小索引单元,可能是一句话、段落、页面、网站、图片……几乎什么都可以。
但这个定义和日常语义里的“文档(年报/合同/证书)”冲突很大,容易把自己绕晕。
所以在后续叙述里:
  • document:我都按日常语义使用(也就是一份报告/文档)
  • 数据库里存储的最小单元我称为 chunk(分块)

Chunking:怎么切块更合理?

比赛规则要求我们输出证据页码,企业级系统也常用同样策略:引用能证明模型答案不是瞎编,同时也更便于开发阶段排查问题。
最简单的 chunk 策略是:一页一个 chunk。因为单页一般不会超过几千 token(当然表格序列化可能会把单页扩到五千 token)。
但从“语义相关性”的角度考虑,一个问题对应的关键事实通常不需要那么大范围。通常十来句就够了。
因此同一句话:
  • 在“小段落 chunk”里更容易拿到高相似度
  • 在“整页 chunk”里会被大量弱相关文字稀释
最终我选择按页拆分后,再把每页切成 300 token 的 chunk(约 15 句),并加 50 token overlap 防止切断信息。
如果担心 overlap 不够,建议搜索“Semantic splitter”(尤其是你打算只把命中的 chunk 塞进上下文时)。
不过,在我的系统里,切块精细度对最终效果影响并不大。
每个 chunk 还会在元数据里记录:chunk ID 与其所属页码。

Vectorization:一份报告一个向量库

chunk 准备好后,下一步就是建向量数据库——更准确地说,是建很多个向量数据库。
我把 100 份报告拆成 100 个独立索引,也就是:1 份报告 = 1 个向量库
原因很简单:为什么要把所有公司的信息混在一个大池子里,之后还得费力区分“苹果的营收”和“微软的营收”?一个问题的答案通常严格来自同一份报告
因此我们的关键任务变成:先判断该查哪份报告(后面会讲 routing)。
向量库实现我用的是 FAISS

向量库索引格式的一点说明

我用 IndexFlatIP 来建索引。
  • Flat 索引的好处:向量“原样存储”,搜索是暴力匹配,精度更高
  • 坏处:计算与内存开销更大
当你的向量数量达到十万级,建议考虑 IVFFlat 或 HNSW:检索更快,但属于 ANN(近似最近邻),速度提升以精度为代价。
由于我把每份报告拆成独立索引,规模被拆小,所以 Flat 索引可行。
相似度我用 IP(inner product)去近似 cosine similarity。除 IP 外也常见 L2(欧氏距离),但 IP 通常效果更好。
embedding 模型我用的是 text-embedding-3-large

3. Retrieval:真正决定“能不能答对”的环节

向量库建完,就轮到 RAG 的 “R”(Retrieval)了。
Retriever 的定义很简单:输入一个 query,返回与之相关的文本(包含回答所需的信息)。
在最基础版本里,就是向量库检索 Top-N chunk。
但检索是整个系统的关键:如果 LLM 的上下文里没有关键证据,再好的 prompt 也无济于事。
Junk in → Junk out
比赛中我尝试过多种提升检索质量的方法,主要包括:

混合检索:向量检索 + BM25

Hybrid Search 的思路是:把语义检索(向量)与关键词检索(BM25)结合,理论上能兼顾“语义”与“精确词命中”。
典型做法是:两路检索各出一批结果,再做合并与重排。
但在我最小实现版本里,这个策略经常不升反降
总体来看,Hybrid Search 仍是一个很有价值的方向,尤其可以通过“改写 query”来提升关键词密度:比如让 LLM 先去掉噪声、重写问题再走 BM25。
如果你对 Hybrid Search 有很好的实践经验(尤其是踩坑与修复手段),欢迎留言交流。

Cross-encoder 重排:效果好但生态限制

Cross-encoder 能对 query 与文本对进行更精确的相关性判断,比只比较 embedding 向量更准确。
问题在于:如果对全库做 pairwise 比对,会太慢,所以它只适合在向量检索先筛出一小批候选后做重排。
我最后放弃这个方向,是因为当时可用的“API 版 cross-encoder”选择太少:OpenAI 或其他大厂并没有直接提供这种能力,我也不想再维护一套额外的推理服务和余额管理。
如果你想试试,我推荐 Jina Reranker:benchmark 表现不错,注册后也有较多免费额度。
最终我选择了一个更“顺手”的替代:LLM 重排

LLM 重排:便宜、快、够好

思路非常直接:把文本块与问题一起发给 LLM,问它:
这段文本对回答这个问题有帮助吗?有多大帮助?请给出 0 到 1 的相关性分数。
以前这样做太贵,但现在我们有足够便宜、够快且足够聪明的模型。
LLM 重排和 cross-encoder 类似:都放在向量检索筛选之后做。
我写了一个相对详细的评分提示词,明确 0.1 为粒度的标准:
  • 0 = 完全无关
  • 0.1 = 极其弱相关
  • 0.2 = 很弱相关
  • 1 = 完全相关
模型输出我用 Structured Output(结构化输出),包含两个字段:
  • reasoning:解释为什么这么打分
  • relevance_score:0~1 的分数
为了提速、降本、也让评分更稳定,我把 三页一起发给模型,请它一次返回三条分数。
最终“纠正后的相关性”用加权平均融合:
  • vector_weight = 0.3
  • llm_weight = 0.7
理论上你也可以完全不做向量检索,把所有页面都喂给 LLM 评分。但这在成本上不划算:对一份 1000 页报告,回答一个问题大约要 0.25 美元——太贵。
向量检索作为第一层过滤仍然必要。我的方案里,使用 GPT-4o-mini 做 LLM 重排,每个问题的重排成本 不到 1 美分,性价比非常高。
重排 prompt 见此:prompts.py

“父页回收”(Parent Page Retrieval):chunk 只是指针

还记得我把文本切成小 chunk 吗?这里有个关键点:
虽然答案信息通常集中在一个小 chunk 里,但同一页的其它文本往往包含“次要但仍重要”的上下文。
因此我在检索到 Top-N chunk 后,并不直接把 chunk 塞进提示词,而是把 chunk 当作“指针”,再把它对应的整页作为上下文送入后续流程。
这也是为什么 chunk 的元数据里必须带页码。

最终的 Retriever 流水线(总结)

 
notion image
完整检索流程如下:
  1. 把 query 向量化
  1. 在向量库里取 Top 30 chunk
  1. 通过 chunk 元数据取回对应页码,并去重
  1. 把这些页丢给 LLM 做重排
  1. 用向量分数与 LLM 分数组合修正
  1. 取 Top 10 页,把每页加上页码前缀并拼成一个字符串作为上下文
Retriever 就绪。

4. Augmentation:拼上下文其实就是字符串工程

 
notion image
向量库与 retriever 搭好后,就进入 “A”(Augmentation)阶段。它整体很直白:主要就是字符串拼接(f-strings)与上下文组织。
一个值得分享的细节是:我如何管理提示词(prompt)。
在做过多个项目的尝试后,我最终固定采用这样的组织方式:
  • 所有 prompt 都放在 prompts.py
  • prompt 按逻辑块拆分存储,例如:
    • 核心 system instruction
    • Structured Output schema(期望的输出结构)
    • one-shot / few-shot 的示例问答
    • 拼接上下文与 query 的模板
然后用一个小函数把这些块组合成最终 prompt 配置。
这个模式的收益是:
  • 便于对比实验不同 prompt 组合
  • 对“多处复用的通用指令”只维护一份,避免同步更新出错
  • prompt 太长时也更容易分段裁剪或替换
完整 prompts 在仓库:prompts.py

5. Generation:最费工、但也最能拉开差距

RAG 的 “G”(Generation)通常是最劳累的部分:要达到高质量,得同时把多种基本功做好。

把问题路由到正确的数据库(Routing: Query → DB)

 
notion image
这是最简单但极其有用的一步。
比赛的题目生成器设计得很“干净”:公司的名字一定会在题干里明确出现。
同时,比赛开始时会提供一份全部公司名称列表。因此,识别公司名完全不需要 LLM:只要遍历名单,用 re.search() 从问题里抽取公司名,再映射到对应向量库即可。
真实业务场景里会更复杂:你可能还需要先给文档打标签,或用 LLM 从问题里抽实体,再对齐到某个库/某个集合。但本质不变:
找公司名 → 匹配 DB → 只在该 DB 内检索,搜索空间直接缩小 100 倍。

把问题路由到正确的 prompt(Routing: Query → Prompt)

 
notion image
比赛还有一个硬要求:答案格式必须严格符合预期类型,好像要直接写进数据库一样简洁。
题目里会明确标注答案类型:int/floatboolstrlist[str]
每种类型都有 3~6 个容易犯错的细节。例如,数值类答案必须:
  • 只输出数字,不要单位/解释
  • 货币要与题目一致
  • 数值要按“千/百万”等单位正确补零
  • 括号可能表示负数
问题是:你不可能让模型一次性稳定遵守太多规则。规则越多,越容易被忽略。对当前 LLM 来说,八条规则已经很危险——额外规则会分散注意力。
因此我采用的策略是:把规则分流。既然题目明确给了答案类型,那么我就写四套 prompt,通过 if else 选用对应版本,只把与当前类型相关的指令塞进去。

复合问题路由(Routing: Compound Query)

 
notion image
比赛里还有一种题:比较多个公司指标,例如:
Who has higher revenue, Apple or Microsoft?
这种题不适合直接走“单次检索-单次回答”。人类会怎么做?
1) 先分别找出 Apple 的营收
2) 再分别找出 Microsoft 的营收
3) 然后比较
我把这个过程显式写进系统:先让 LLM 把比较题拆成两个子问题(分别抽指标),再把两个子问题分别走标准 pipeline,最后把两边答案作为上下文,再回答原始比较题。
这个模式可以推广到各种复杂查询:关键在于识别其结构,并拆成必要子步骤。

Chain-of-Thought(CoT):让模型“按步骤推理”

CoT 的核心价值是:让模型在给最终答案前先“把推理过程说出来”。像人一样把复杂问题拆成更小步骤,能显著提高正确率,尤其是当上下文里有很多“相似但不等价”的信息时。
你可能听过早期最经典的提示词:Think step by step。它确实开启了 prompt engineering 的浪潮,但对于严肃任务远远不够。
LLM 也会“假装推理”:先拍一个答案,然后倒过来编一段推理去解释它。弱模型(例如 GPT-4o-mini、Llama 8b)尤其常见。
因此要让 CoT 真正有用,你需要明确告诉模型应该如何推理:步骤目标是什么、需要检查什么、最好还要给例子。把推理结构显式化,也能显著提升模型对规则的遵循。
在 RAG 场景里,一个常见问题是:报告里没有“完全匹配”的指标,但有相近指标。模型因为“想帮你”,容易把相近信息硬掰成答案。仅靠一句“保持怀疑”并不稳定。
我最终的策略是:明确要求模型在推理里重点评估“题目指标与上下文指标是否同一个口径”,不匹配则必须返回 N/A。

一个上下文含糊的推理示例

问题:
What is the Research and development equipment, at cost, for Ritter Pharmaceuticals, Inc.?
模型推理步骤(重写表达,但信息保持一致):
  1. 问题要的是 Ritter Pharmaceuticals, Inc. 的“研发设备(按成本计)”。这里的“at cost”意味着要的是设备原始购置成本,而不是折旧后净值。
  1. 上下文(第 35 页)给的是 Property and equipment, net 为 $12,500。这个是净值(扣除折旧后),而且范围也更广,不等同于“研发设备成本”。
  1. 上下文(第 37 页)提到 Accumulated Depreciation 为 $110,000,针对 Machinery and Equipment。这只是累计折旧,不是成本,而且也没有说明是研发设备。
  1. 这两项都无法与问题指标严格对齐:一个口径太宽且是净值,一个只给折旧且缺乏“研发设备”的限定。
  1. 因此上下文并未直接给出所需指标,不能推算或假设,最终答案应为 N/A

Structured Output(结构化输出):把输出锁进 JSON

Structured Output(SO)本质上是用一个严格的结构(例如 Pydantic/JSON schema)约束模型输出,确保返回值始终可解析、字段完整。
例如做 LLM 重排时,我用一个包含两个字段的 schema:reasoningrelevance_score
如果一个模型原生支持 SO,就可以直接做到“永远返回合法 JSON”。字段描述也可以写入 schema,作为 prompt 的一部分,进一步引导模型。

CoT + SO:推理与答案分开存

理想情况下,这些方法要组合起来用。
在 generation 阶段,模型输出里会有一个字段专门放推理,另一个字段专门放最终答案。这样我们只取最终答案字段即可,避免从长推理里再做字符串解析。
我主力回答 schema 里有四个字段:
  • step_by_step_analysis:完整推理(CoT)
  • reasoning_summary:对推理做简明摘要(便于追踪)
  • relevant_pages:引用到的证据页码
  • final_answer:最终答案(严格按题目类型格式化)
前三个字段在不同答案类型下通用;第四个字段会随答案类型变化,并在 schema 中做硬约束。
例如要确保 final_answer 要么是数字要么是 "N/A",我会用类似定义:
final_answer: Union[float, int, Literal['N/A']]

SO Reparser:让“不听话的模型”回炉重写

并非所有模型都支持“原生 Structured Output”。如果模型只是在 prompt 里看到 schema,它通常能返回合法 JSON,但仍会有一部分输出偏离结构,导致程序崩溃——小模型可能一半都不合规。
我的兜底做法是:
  1. 先用 schema.model_validate(answer) 校验输出
  1. 如果校验失败,就把模型的输出再发回给模型,让它“按 schema 重新输出一遍”
这能把 schema compliance 拉回到 100%,即使是 8b 模型也一样。
对应 prompt 在这里:prompts.py

One-shot:用一个高质量示例“定调”

另一个常见且有效的方法是 one-shot:在 prompt 里加一个“问题→答案”的示例对,能显著提升输出一致性。
我在每套 prompt 里都加了一个示例,示例答案直接按 Structured Output 的 JSON 结构写。
示例的作用不止一个:
  • 演示一套“理想的逐步推理”
  • 在一些 tricky case 上校正模型偏置
  • 让不支持原生 SO 的模型也能对齐输出结构
我对示例答案的质量要求非常严格:示例若与指令矛盾,会直接把模型搞糊涂,导致效果下降。

指令打磨:最耗工的部分

这一部分的工作量几乎不亚于“数据准备阶段”。因为你需要不断:
  • 调试、跑验证集
  • 复盘错题
  • 人工分析模型推理过程
  • 迭代修改指令、示例与规则

先把“问题本身”研究透

写 prompt 之前,我把题目格式要求与题目生成器都仔细研究了一遍,并生成题目做了一套验证集,手动去回答一部分题,主要为了两件事:
  1. 验证集能客观衡量系统质量变化(改动后到底提升了多少)
  1. 人工答题能暴露很多题目隐藏细节与歧义,从而把规则写清楚
我非常认同一个观点:要做高质量 QA 系统,必须先深度理解用户/客户的问题是什么——否则很难做到稳定可靠。

“解释自由阈值”问题(Interpretation Freedom Threshold)

举个例子:问题问 Who is the CEO of ACME inc?
理想情况下,报告会明确写:
CEO responsibilities are held by John Doe
这时系统很简单:检索到这句话,答案就是 John Doe
但现实是:信息表达方式千差万别,“CEO”究竟指谁?可能出现的职位包括:
  • Chief Executive Officer(最直接)
  • Managing Director(欧洲/英国常见)
  • President(美国、日本常见)
  • Executive Director(非营利/一些地区)
  • 甚至还有 COO、Principal Executive Officer、General Manager、Administrative Officer、Representative Director 等“相近但不等价”的角色
当答案允许自由表达时,模型可以把这些不确定性写成解释。但在比赛这种“必须短、必须严格类型”的场景里,模型会变得很不稳定,更多靠“直觉猜”。
因此需要提前定义并校准“解释自由阈值”:哪些情况允许放宽解释,哪些情况必须严格字面匹配——但这又很难用一条明确规则穷举,所以你需要系统性枚举边界情况,反复调参。
除此之外还有其它两难题,例如:
Did ACME inc announce any changes to its dividend policy?
如果报告里没有提到分红政策变化,是否应当返回“没有变化”?还是“未知”?
这类问题在准备阶段我问了组织者很多次 :)

Prompt 版本与配套 prompt 清单

我最终为每种问题/输出类型做了不同 prompt,并额外准备了辅助 prompt:
  • 数值题(Number)
  • 单个名字(Name)
  • 多个名字(Names)
  • 布尔题(Boolean)
  • 比较题(Comparative:需要多查询路由与比较)
  • 比较题的改写 prompt(用来把复合问题拆成子问题)
  • LLM 重排 prompt
  • SO Reparser prompt
指令的精细打磨 + one-shot + SO + CoT 的组合,让最终 prompt 能稳定压制一些不想要的偏置,并显著提升对细节(尤其是单位、口径)的关注度,即使在小模型上也有明显收益。

系统速度:2 分钟跑完 100 题

最初的比赛规则更严格:必须在 10 分钟内回答完 100 题才有资格领奖。我非常认真对待这个限制,并尝试把 OpenAI 的 TPM(Tokens Per Minute)上限用满。
即使是 Tier 2,限制也很宽松:GPT-4o-mini 为 2M tokens/min,GPT-4o 为 450k tokens/min。我估算了每题 token 消耗后,把问题分成每批 25 个并行处理。
最终系统只用了 2 分钟就答完 100 题。
后来提交时限被大幅延长——因为其他选手确实跑不完 :)

系统质量:表格序列化反而没带来提升

验证集不仅帮助我优化 prompt,也帮助我评估整条 pipeline 的关键开关。我把关键策略都做成配置项,便于 A/B 测试。示例配置如下:
class RunConfig: use_serialized_tables: bool = False parent_document_retrieval: bool = False use_vector_dbs: bool = True use_bm25_db: bool = False llm_reranking: bool = False llm_reranking_sample_size: int = 30 top_n_retrieval: int = 10 api_provider: str = "openai" answering_model: str = "gpt-4o-mini-2024-07-18"
在多轮测试里我意外发现:我很看好的“表格序列化”不仅没有提升,反而略微降低了效果。
原因可能是:Docling 对 PDF 表格解析已经足够好,retriever 也能有效命中,LLM 也能在不序列化的情况下理解表格结构;而序列化会让页面文字变多、噪声变大,反而降低信噪比。
我还为比赛准备了多套配置,方便在不同类别下快速切换不同系统版本。
最终系统在开源与闭源模型上都表现不错:Llama 3.3 70b 只比 OpenAI o3-mini 低几个点;甚至 Llama 8b 也能超过 80% 的参赛者。

结语:RAG 的“魔法”来自细节

赢得这场比赛并不是因为某个“神奇单点”,而是因为系统化地把每个环节都做到位,并在细节上反复打磨:高质量解析、有效检索、智能路由,以及最关键的——LLM 重排与精心设计的提示词,让系统即使在更小的模型上也能获得优秀表现。
这次经历最大的收获是:RAG 的魔法藏在细节里。你越理解任务,就越能精准调优每一个模块;即使使用最朴素的方法,也能获得更高收益。
系统代码已开源,包含部署与运行各阶段的说明:
Ilya 也一直欢迎有趣的想法、项目与合作。可以通过 TelegramLinkedIn 联系。
 
随机漫谈-01-关于AI的性格与人类的“拟人化本能”ClaudeCode使用技巧:官方关于会话管理和上下文管理的建议