Enterprise RAG Challenge 冠军方案深度拆解:研报级学习笔记


Enterprise RAG Challenge 冠军方案深度拆解:研报级学习笔记

方案来源 : Ilya Rice --- How I Won the Enterprise RAG Challenge | 开源代码
成绩 : 两个奖项类别均第一 + SotA榜首 | 核心模型: GPT-4o-mini (Reranking) + o3-mini (Answering)


引言

plain 复制代码
=== 输入输出梳理 ===
输入: 100份企业年报PDF(每份最多1000页)+ 100道自动生成的问答题
输出: 每题一个简洁答案(int/float/bool/str/list[str])+ 证据页码引用

=== 核心难点识别 ===

难点1: PDF解析的"信息无损"问题
- 旋转90°的大表格、Caesar cipher编码文本、多列混排、图片+文字混合图表
- Engineering Trick: Docling + DoclingParseV2Backend + EasyOCR兜底 + 正则表达式批量清洗
  特别精妙:检测到cipher编码的文档整个走OCR路径

难点2: 大表格语义检索失效
- 表头与数据距离太远(1500 tokens隔开),embedding相似度大幅下降
- Engineering Trick: 表格序列化(HTML→LLM→attribute-value pair),但最终发现反而有害(加噪),
  最终winning config中 use_serialized_tables=False ← 这是反直觉的重要发现!

难点3: 多公司DB的检索空间爆炸
- 100个公司混在一起,答案永远只在单个公司的报告里
- Engineering Trick: per-company FAISS索引 + 正则表达式公司名路由,搜索空间缩小100倍

难点4: LLM认知容量有限,规则越多越容易忘
- 4种答案类型各有3-6个特殊规则(货币、千位/百万单位、括号负数、N/A条件)
- Engineering Trick: 按answer type路由到4套独立prompt,而非把所有规则塞进一个prompt

难点5: LLM幻觉页码引用
- LLM会引用它"想象"出来的页码,不在检索结果里
- Engineering Trick: _validate_page_references()后处理,把不在retrieval_results里的页码全部剔除

难点6: 2.5小时时间窗口,100份×最多1000页文档
- Engineering Trick: 
  1) RunPod 4090 GPU加速Docling解析(40分钟搞定15000页)
  2) ThreadPoolExecutor并行处理questions(25个并行,2分钟答完100题)
  3) LLM Reranking batch:3页一批给GPT-4o-mini,既省钱(<$0.01/题)又提速

=== 最反直觉的发现 ===
- 表格序列化(精心设计的大工程)最终hurt而非help,因为Docling已经解析得够好了
- 加更多文本=加噪,反而降低检索信噪比
- 小模型 Llama 8b + 这套pipeline 胜过80%参赛者!说明pipeline工程比模型选择更重要

项目全局视角

业务痛点与赛题设定

这不是一个普通的 RAG 问答题------它是一个极端压力测试

约束条件 具体数值 工程挑战
文档规模 100份PDF,每份最多1000页 15,000页总量,2.5小时内解析完
答案格式 int/float/bool/str/list[str] 5种严格类型 不能含单位、不能含注释、货币必须匹配
证据要求 每答案必须附页码引用 反幻觉的硬性约束
时间窗口 原始规则要求100题10分钟内 强制并行化设计

该方案的"胜负手"

不是某个单一的魔法技术,而是五层协同效应的叠加:

  1. Docling定制解析 → 信息无损的Markdown+HTML输出
  2. Per-Company独立FAISS索引 → 检索空间缩小100倍
  3. Parent Document Retrieval → 小块定位+全页上下文的最优平衡
  4. LLM Reranking(GPT-4o-mini) → 将向量检索的0.3权重与LLM语义判断0.7权重加权融合
  5. 三路路由 + CoT Structured Output → 消除规则混乱,强制推理链

多模态数据摄入与解析 (Data Ingestion & Parsing)

What: Docling + DoclingParseV2Backend

python 复制代码
# src/pdf_parsing.py 核心配置
pipeline_options = PdfPipelineOptions()
pipeline_options.do_ocr = True
ocr_options = EasyOcrOptions(lang=['en'], force_full_page_ocr=False)  # 按需OCR
pipeline_options.ocr_options = ocr_options
pipeline_options.do_table_structure = True  # 表格结构识别
# TableFormerMode = ACCURATE 模式(代码后段)

Why: 为什么选Docling而不是其他方案

作者测试了约20款解析器,包括:

  • 商业API(Adobe PDF Services等)
  • 传统解析库(pdfplumber, PyMuPDF)
  • ML增强解析器(Unstructured.io等)

Docling的核心优势(IBM开发,这也是赛事组织者之一的产品):

  • 原生支持GPU加速(NVIDIA 4090 上40分钟处理15000页)
  • **TableFormer模型:**专门训练的表格结构识别,输出同时包含Markdown和HTML两种格式
  • 可以输出包含完整元数据的JSON(位置、页码、类型标注)

How: 解析管道的四个阶段

plain 复制代码
PDF
 │
 ▼
[Docling DoclingParseV2Backend]
 │ → JSON(含元数据:page, block_type, table_id)
 │ → HTML格式表格(关键!)
 ▼
[parsed_reports_merging.py - PageTextPreparation]
 │ → 复杂JSON → 简化的 {pages: [{page: N, text: "...markdown..."}]} 结构
 ▼
[正则表达式批量清洗]
 │ → 修复font encoding问题(Caesar cipher文档 → 整体走OCR路径)
 │ → 清理PDF解析artifact
 ▼
[text_splitter.py - TextSplitter]
 │ → RecursiveCharacterTextSplitter(Langchain)
 │ → chunk_size=300 tokens, chunk_overlap=50 tokens
 └─ → 每chunk保留 {page: N, text: "...", id: K, type: "content"}
工具 角色 备注
Docling (IBM) 主力 PDF 解析器 支持 GPU 加速,RTX 4090 解析 100 份报告仅 40 分钟
OCR 回退 处理 Caesar 密码字体编码异常文档 用 regex 检测后整页走 OCR
自定义 Regex 批处理 清洗解析噪声(错误语法、页眉页脚残余) 约 12 条正则规则

关于Caesar Cipher的工程处理------这是最精彩的细节之一:

某些PDF字体编码破损,视觉上看文字正常,但程序提取出来是乱码。作者发现这是「每个词的ASCII码按不同偏移量位移」的**变体Caesar cipher(可能是打印厂的字体库Bug)。解码后仍有大量Artifact,因此对这类文档采用 整体强制OCR**策略而非尝试解码。**通过 regex 识别后直接 OCR 全页,是一个务实的工程 Fallback**。

正则清洗的工程哲学:用一批正则(文中提到"dozen正则")批量处理解析Artifact,而不是个案处理------这是生产系统的正确思路。

切块策略 (Chunking) 的精髓:小块检索 + 大块阅读

这是整个架构中最优雅的设计权衡

策略 chunk_size chunk_overlap 检索精度 答题上下文
纯页级别(朴素) ~2000 tokens 0 低(语义稀释) 充足
纯小块(极端) 100 tokens 0 不足
本方案(两段式) 300 tokens检索 50 tokens 全页(Parent Retrieval)

核心insight :chunk是指针 ,不是内容 。找到chunk后,用chunk.page查到完整页面文本,把整页喂给LLM。

python 复制代码
# src/text_splitter.py
def _split_page(self, page, chunk_size=300, chunk_overlap=50):
    text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
        model_name="gpt-4o",  # 使用gpt-4o的tokenizer计数
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap
    )
    chunks = text_splitter.split_text(page['text'])
    # 每个chunk携带其所在的page编号 ← 这是Parent Retrieval的关键
    return [{"page": page['page'], "text": chunk, ...} for chunk in chunks]
  • 每个 Chunk 保存 page 元数据作为父页指针
  • 检索时找到精准 Chunk → 通过元数据回溯完整页面送入上下文(Parent Document Retrieval)
  • 这是 Small-to-Big Retrieval 模式的典型工程实现

表格序列化的「美丽失败」

表格序列化的设计逻辑极为精妙:将大表格转换为独立的语义完整字符串:

plain 复制代码
原始大表:
| 指标\年份 | 2019 | 2020 | 2021 | 2022 |
| 股东权益  | 637  | 535  | 577  | 1274 | (单位:百万日元)

序列化后:
subject_core_entity: Shareholders' equity
information_block: Shareholders' equity for years 2019-2022: 
  ¥637,422M (2019/3), ¥535,422M (2020/3), ¥577,782M (2021/3), ¥1,274,570M (2022/3)

但最终 use_serialized_tables=False 是winning config!


原因分析:

  • Docling的TableFormer已经把表格解析得足够好
  • 序列化后额外增加的文本 = 增加噪声 = 降低信噪比
  • 对于300-token的小块检索,多余文本会「稀释」相关chunk的向量相似度分数

工程教训:不要先入为主地认为"更多处理=更好结果"。A/B测试胜过直觉。


检索与召回引擎 (Retrieval & Reranking)

架构设计:Per-Company独立FAISS索引

python 复制代码
# src/ingestion.py - VectorDBIngestor
def _create_vector_db(self, embeddings):
    embeddings_array = np.array(embeddings, dtype=np.float32)
    dimension = len(embeddings[0])
    index = faiss.IndexFlatIP(dimension)  # IP = Inner Product = 余弦相似度
    index.add(embeddings_array)
    return index

**Why ****IndexFlatIP**而不是IndexIVFFlat/HNSW?

索引类型 搜索方式 精度 速度 适用规模
IndexFlatIP 全量暴力搜索 100%精确 慢(线性) <10万向量
IndexIVFFlat 近似最近邻(分桶) 略有损失 >10万向量
IndexHNSW 图近邻近似 略有损失 最快 >100万向量

每个公司的报告平均约150页×每页约6个chunk = ~900个向量/公司。这个规模下暴力搜索几乎是瞬时的,选FlatIP是正确的。

Per-Company独立索引的核心优势

  • 搜索空间从 100公司 × 900向量 = 90,000 缩小到 900向量/次(缩小100倍)
  • 完全消除跨公司信息混淆的可能性
  • 路由错误 = 答案必错,但路由逻辑极简(正则匹配公司名)

嵌入模型选择:text-embedding-3-large

OpenAI的text-embedding-3-large,维度3072,在MTEB榜单上表现优秀。选择它的pragmatic理由:

  • 比开源模型更稳定的API(竞赛中不希望管理本地服务)
  • 与FAISS无缝集成
  • 批量嵌入:每批最多1024个文本(代码中的text_chunks = [text[i:i+1024]...]

LLM Reranking:整个方案的核心胜负手

这是最精妙的设计,完整实现在src/reranking.py

python 复制代码
# src/reranking.py - LLMReranker.rerank_documents()
def rerank_documents(self, query, documents, documents_batch_size=4, llm_weight=0.7):
    # 将documents分成每批documents_batch_size个
    doc_batches = [documents[i:i+documents_batch_size] 
                   for i in range(0, len(documents), documents_batch_size)]
    vector_weight = 1 - llm_weight  # 0.3
    
    def process_batch(batch):
        texts = [doc['text'] for doc in batch]
        # 一次API调用评估整批文档 ← 这是降本增效的关键
        rankings = self.get_rank_for_multiple_blocks(query, texts)
        results = []
        for doc, rank in zip(batch, rankings['block_rankings']):
            doc_with_score = doc.copy()
            doc_with_score["combined_score"] = round(
                llm_weight * rank["relevance_score"] +   # 0.7 * LLM评分
                vector_weight * doc['distance'],          # 0.3 * 向量余弦相似度
                4
            )
            results.append(doc_with_score)
        return results
    
    # 并行处理所有批次
    with ThreadPoolExecutor() as executor:
        batch_results = list(executor.map(process_batch, doc_batches))
    
    all_results.sort(key=lambda x: x["combined_score"], reverse=True)
    return all_results

完整的Reranking流程(实际winning config参数):

plain 复制代码
查询向量化
    ↓
FAISS检索 Top-30 chunks(llm_reranking_sample_size=30)
    ↓
Parent Document Retrieval:chunks → 对应完整页面(去重)
    ↓
LLM Reranker(GPT-4o-mini,batch_size=2,即每次2页)
    └→ combined_score = 0.7×LLM_relevance + 0.3×cosine_similarity
    ↓
取Top-10页面(top_n_retrieval=10)
    ↓
拼接为RAG上下文字符串,送入Answering LLM

Reranking的**Structured Output Schema**(精华):

python 复制代码
# src/prompts.py
class RetrievalRankingSingleBlock(BaseModel):
    """Rank retrieved text block relevance to a query."""
    reasoning: str = Field(
        description="Analysis of the block, identifying key information and how it relates to the query"
    )
    relevance_score: float = Field(
        description="Relevance score from 0 to 1, where 0 is Completely Irrelevant and 1 is Perfectly Relevant"
    )

Relevance Score的11档精确定义(0.0, 0.1, 0.2 ... 1.0)让模型有清晰的"锚点",而**reasoning**字段强制模型在打分前先推理(CoT效果)。

批量评分(Multiple Blocks)的妙处

  • 同一批次的多个块互相参照,模型能给出相对一致的分数(比单独评分更稳定)
  • 一次API调用处理2-3个文档,成本约是单独调用的1/2

成本核算

  • Reranking成本:每题 < $0.01(GPT-4o-mini)
  • 相比把整个1000页文档全部过LLM(约$0.25/题),节省25倍成本

BM25的存在与选择性使用

python 复制代码
# src/ingestion.py - BM25Ingestor
class BM25Ingestor:
    def create_bm25_index(self, chunks):
        tokenized_chunks = [chunk.split() for chunk in chunks]  # 简单空格分词
        return BM25Okapi(tokenized_chunks)

BM25(Best Match 25)作为补充实现了,但最终winning config没有用它use_bm25_db=False)。

**作者的结论:BM25在这个场景中"往往降低了检索质量"。作者发现简单合并 Dense+Sparse 在本任务上不稳定,有时反而降低质量 ------这与业界常见认知相符:****Hybrid Search 需要精细调优。**原因分析:

  • 问题是自然语言问句,与文档中的专业财务术语的词汇overlap较低
  • 简单空格分词没有stemming/lemmatization,匹配能力弱
  • LLM Reranking已经能弥补纯向量检索的不足,BM25多此一举

Agent编排与防幻觉

三路路由机制

这是整个方案中架构最优雅的部分:

路由1:数据库路由(DB Router)
python 复制代码
# src/questions_processing.py - QuestionsProcessor._extract_companies_from_subset()
def _extract_companies_from_subset(self, question_text):
    company_names = sorted(self.companies_df['company_name'].unique(), 
                           key=len, reverse=True)  # 长名字优先匹配,防止子串问题
    found_companies = []
    for company in company_names:
        escaped_company = re.escape(company)
        pattern = rf'{escaped_company}(?:\W|$)'
        if re.search(pattern, question_text, re.IGNORECASE):
            found_companies.append(company)
            question_text = re.sub(pattern, '', question_text, flags=re.IGNORECASE)
    return found_companies

Why不用LLM做公司名提取?

  • 赛题规则保证公司名总是明确出现在问题中
  • 正则匹配比LLM快100倍,成本为零
  • 确定性输出,不会幻觉出不存在的公司名

实现的关键细节

  • 按名字长度降序排列,先匹配长名字(防止"Apple"匹配到"Apple Inc."中的子串)
  • 匹配后将该名字从问题文本中删除,防止重复匹配
路由2:Prompt路由(Schema Router)
python 复制代码
# src/api_requests.py 中的路由逻辑(基于kind字段)
PROMPT_MAP = {
    "number": AnswerWithRAGContextNumberPrompt,
    "name":   AnswerWithRAGContextNamePrompt,
    "names":  AnswerWithRAGContextNamesPrompt,
    "boolean": AnswerWithRAGContextBooleanPrompt,
    "comparative": ComparativeAnswerPrompt
}
# 根据 question["kind"] 选择对应Prompt类

四套独立Prompt的设计哲学:LLM的认知资源是有限的。**把所有类型的规则塞进一个prompt,模型会"顾此失彼"。**独立化后:

  • number prompt:专注货币匹配、千位/百万换算、括号负数、精确匹配(不允许计算推导)
  • name prompt:专注人名全称提取、N/A判定
  • names prompt:专注职位名称(单数形式!)、产品名(候选产品不算)
  • boolean prompt:专注"有无"的语义判断

Number Prompt中最硬核的规则(直接看Field描述):

python 复制代码
# src/prompts.py - AnswerWithRAGContextNumberPrompt.AnswerSchema
final_answer: Union[float, int, Literal['N/A']] = Field(description="""
# 货币不匹配 → N/A
- Return 'N/A' if metric provided is in a different currency than mentioned in the question

# 禁止推导计算 → N/A(防止LLM自作聪明做算术)
- Return 'N/A' if metric is not directly stated in context EVEN IF it could be 
  calculated from other metrics

# 千位/百万换算的内嵌示例(Few-shot)
- Example for numbers in thousands:
    Value from context: 4970,5 (in thousands $)
    Final answer: 4970500

# 括号=负数
- Pay attention if value wrapped in parentheses, it means the value is negative
    Value from context: (2,124,837) CHF
    Final answer: -2124837
""")
路由3:复合问题路由(Multi-Query Router)
python 复制代码
# src/questions_processing.py - process_comparative_question()
def process_comparative_question(self, question, companies, schema):
    # Step 1: LLM分解问题
    rephrased_questions = self.openai_processor.get_rephrased_questions(
        original_question=question,
        companies=companies
    )
    # 例: "Which has higher revenue, Apple or Microsoft?" 
    # → {"Apple": "What was Apple's revenue?", "Microsoft": "What was Microsoft's revenue?"}
    
    # Step 2: 并行处理每个子问题
    with concurrent.futures.ThreadPoolExecutor() as executor:
        future_to_company = {
            executor.submit(process_company_question, company): company 
            for company in companies
        }
        # 各公司的答案并行获取
    
    # Step 3: 汇总后送给ComparativeAnswerPrompt做比较
    comparative_answer = self.openai_processor.get_answer_from_rag_context(
        question=question,
        rag_context=individual_answers,  # 传入各公司的独立答案
        schema="comparative"
    )

CoT + Structured Output:防幻觉的核心机制

四字段Schema的设计是精髓:

python 复制代码
class AnswerSchema(BaseModel):
    # 字段1:强制推理(至少5步、至少150词)
    step_by_step_analysis: str = Field(
        description="Detailed step-by-step analysis... at least 5 steps and at least 150 words."
    )
    # 字段2:提炼摘要(约50词,便于调试)
    reasoning_summary: str = Field(
        description="Concise summary of the step-by-step reasoning process. Around 50 words."
    )
    # 字段3:页码引用(后处理验证)
    relevant_pages: List[int] = Field(...)
    
    # 字段4:最终答案(严格类型约束)
    final_answer: Union[float, int, Literal['N/A']] = Field(...)

CoT 防幻觉的具体指令设计:

  • 明确区分"被请求度量"和"类似但不完全相同的度量"
  • 强制分析单位(千/百万)并要求补零(避免量级错误)
  • 对括号内数值(负数)、不同货币单位等边缘情况逐一在 Prompt 中标注

CoT的防幻觉机制详解

Number类型的step_by_step_analysis字段描述中,明确规定了5步推理链

  1. 确定问题中指标的精确含义(是什么?)
  2. 审查上下文中的候选指标(找什么?)
  3. 仅当指标含义完全匹配才接受(不接受同类但不同含义的指标)
  4. 拒绝条件:上下文指标比问题指标宽泛/狭窄、需要计算推导、聚合不匹配
  5. 若有任何疑问,默认返回N/A

这5步推理链的设计针对性地解决了 最常见的幻觉模式:LLM倾向于"找相似的"而不是"找完全一致的"。

SO Reparser:最后一道防线

python 复制代码
# src/prompts.py - AnswerSchemaFixPrompt
class AnswerSchemaFixPrompt:
    system_prompt = """
You are a JSON formatter.
Your task is to format raw LLM response into a valid JSON object.
Your answer should always start with '{' and end with '}'
Your answer should contain only json string, without any preambles, comments, or triple backticks.
"""
    user_prompt = """
Here is the system prompt that defines schema:
"{system_prompt}"

Here is the LLM response that not following the schema:
"{response}"
"""

实现逻辑

  1. schema.model_validate(answer) 验证Pydantic schema
  2. 验证失败 → 把原始response + schema定义 → 发给LLM重新格式化
  3. 对小模型**(Llama 8b)**尤其关键,能将schema合规率从~50%提升到100%

页码引用的后处理防幻觉

python 复制代码
# src/questions_processing.py - _validate_page_references()
def _validate_page_references(self, claimed_pages, retrieval_results, min_pages=2, max_pages=8):
    retrieved_pages = [result['page'] for result in retrieval_results]
    
    # 剔除LLM"发明"的页码
    validated_pages = [page for page in claimed_pages if page in retrieved_pages]
    
    if len(validated_pages) < len(claimed_pages):
        removed = set(claimed_pages) - set(validated_pages)
        print(f"Warning: Removed {len(removed)} hallucinated page references: {removed}")
    
    # 如果有效引用太少,从检索结果中补充
    if len(validated_pages) < min_pages and retrieval_results:
        for result in retrieval_results:
            if result['page'] not in set(validated_pages):
                validated_pages.append(result['page'])
            if len(validated_pages) >= min_pages:
                break
    
    return validated_pages[:max_pages]  # 最多8页

这个后处理函数优雅地解决了:**"LLM说答案在第42页,但第42页根本没有在检索结果里"**这个经典幻觉场景。


大白话费曼类比

类比一:整个RAG系统 = 图书馆考试助手

想象你是一个公司年报图书馆的管理员,有100个独立的书架,每个书架对应一家公司(Per-Company FAISS索引)。

同学来问:"苹果公司2022年的总资产是多少?"

第一步(DB路由):你听到"苹果公司",立刻走到苹果公司那个书架(而不是搜索100个书架)。

第二步(向量检索):你把问题转换成一张"需求卡片"(embedding向量),然后用卡片在书架上快速扫描,找出30张"长得最像"的卡片对应的书页(Top-30 chunks)。

第三步(Parent Document Retrieval):你找到的是书签,不是书页。用书签找到对应的完整书页------因为回答问题需要读整页,不是读只言片语。

第四步(LLM Reranking):你请一个聪明的助手(GPT-4o-mini)快速浏览这30页,给每页打0-1分:"这页能回答这个问题吗?"助手每次看2-3页,并发打分(ThreadPoolExecutor)。

第五步(CoT回答):把评分最高的10页交给主考官(o3-mini),他必须按5步推理流程作答:先确认问题问的是什么,再找证据,再验证匹配,再给出答案,绝不允许猜测。

第六步(防幻觉):主考官报告"答案在第78页",你检查书签里确实有第78页------如果没有,把这个幻觉页码划掉。

类比二:LLM Reranking = 招聘的二轮面试制度

你要从1000名简历中(所有chunks)选出最匹配岗位(问题)的员工。

第一轮(向量检索,海选):HR用关键词搜索系统快速筛出30份简历(向量余弦相似度)。这一步快但不精准------可能漏掉表述方式不同的好候选人,也会混入"表面匹配"的差候选人。

第二轮(LLM Reranking,精选):部门主管(GPT-4o-mini)亲自读这30份简历,给每份打1-10分并写评语(reasoning字段)。这一步慢但精准。

最终录用(Combined Score):最终得分 = 主管评分×0.7 + 系统匹配分×0.3。主管的判断权重更高,但不完全丢弃系统筛选的信息。

批量面试(batch_size=2-3):主管每次同时读2-3份简历,横向对比后打分------比一份份单独读更一致,更省时。每批花费<$0.01。


可直接"抄作业"的神级工程Trick

Trick 1:Prompt类型路由 + Pydantic Schema自描述

这是代码最值得复用的设计模式------Prompt即文档,Schema即规则

python 复制代码
# 可复用的Prompt路由框架(仿写,加满注释)
from pydantic import BaseModel, Field
from typing import Union, Literal, List
import inspect, re

class NumberAnswerPrompt:
    """数值类问题的Prompt,包含所有防幻觉规则"""

    instruction = """
You are a RAG answering system. Answer ONLY based on provided context.
Before answering, think step by step with at least 5 steps.
"""

    class Schema(BaseModel):
        step_by_step_analysis: str = Field(
            description="5-step analysis. Step1: what exactly does the metric mean? "
            "Step2: find candidates in context. Step3: strict match check. "
            "Step4: reject if ambiguous. Step5: conclude."
        )
        reasoning_summary: str = Field(description="50-word summary for debugging")

        # 关键:final_answer的description就是业务规则文档
        final_answer: Union[float, int, Literal['N/A']] = Field(description="""
- Numbers in thousands: multiply by 1000 (e.g., '4970 (in thousands)' → 4970000)
- Parentheses = negative: (2,124,837) → -2124837
- Currency mismatch → N/A
- Cannot be calculated → N/A (must be directly stated)
- Not in context → N/A
""")

    # 动态从Schema类源码生成pydantic_schema字符串(作者的巧妙做法)
    pydantic_schema = re.sub(r"^ {4}", "", inspect.getsource(Schema), flags=re.MULTILINE)

# 路由字典:简洁、可扩展
PROMPT_ROUTER = {
    "number": NumberAnswerPrompt,
    "boolean": BoolAnswerPrompt,
    "name": NameAnswerPrompt,
}

def route_and_answer(question_kind: str, context: str, question: str):
    prompt_cls = PROMPT_ROUTER[question_kind]
    # 使用 llm.beta.chat.completions.parse() 获得结构化输出
    response = llm.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": prompt_cls.instruction},
            {"role": "user", "content": f"Context: {context}\nQuestion: {question}"}
        ],
        response_format=prompt_cls.Schema  # 自动强制JSON格式
    )
    return response.choices[0].message.parsed

Trick 2:LLM Reranker + 加权融合(可即插即用)

python 复制代码
# 可直接复用的LLM Reranker核心逻辑
from pydantic import BaseModel, Field
from openai import OpenAI
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict

class BlockRanking(BaseModel):
    reasoning: str = Field(description="Why this block is or isn't relevant")
    relevance_score: float = Field(description="0=irrelevant, 1=perfectly answers the query")

class BatchRanking(BaseModel):
    block_rankings: List[BlockRanking]

def llm_rerank(
    query: str,
    documents: List[Dict],  # [{"text": "...", "distance": 0.85, ...}]
    llm_weight: float = 0.7,
    batch_size: int = 3,
    top_n: int = 10
) -> List[Dict]:
    """
    核心算法:
    combined_score = llm_weight × llm_relevance + (1-llm_weight) × cosine_similarity
    """
    client = OpenAI()
    vector_weight = 1 - llm_weight
    
    def score_batch(batch: List[Dict]) -> List[Dict]:
        # 将多个文档格式化为带编号的块
        blocks = "\n\n---\n\n".join(
            [f"Block {i+1}:\n\"\"\"\n{doc['text']}\n\"\"\"" for i, doc in enumerate(batch)]
        )
        user_msg = f'Query: "{query}"\n\nBlocks:\n{blocks}\nProvide exactly {len(batch)} rankings.'
        
        # Structured Output保证JSON格式正确
        completion = client.beta.chat.completions.parse(
            model="gpt-4o-mini",
            temperature=0,
            messages=[
                {"role": "system", "content": "Rate each block's relevance to the query (0-1)."},
                {"role": "user", "content": user_msg}
            ],
            response_format=BatchRanking
        )
        rankings = completion.choices[0].message.parsed.block_rankings
        
        results = []
        for doc, rank in zip(batch, rankings):
            scored = doc.copy()
            scored["llm_score"] = rank.relevance_score
            # 加权融合:LLM判断 + 向量相似度
            scored["combined_score"] = llm_weight * rank.relevance_score + vector_weight * doc["distance"]
            results.append(scored)
        return results
    
    # 分批并行处理(降低延迟)
    batches = [documents[i:i+batch_size] for i in range(0, len(documents), batch_size)]
    with ThreadPoolExecutor() as executor:
        all_results = [item for batch_result in executor.map(score_batch, batches) for item in batch_result]
    
    return sorted(all_results, key=lambda x: x["combined_score"], reverse=True)[:top_n]

Trick 3:页码引用幻觉过滤器(防幻觉后处理)

python 复制代码
def validate_page_references(
    claimed_pages: List[int],
    retrieval_results: List[Dict],
    min_pages: int = 2,
    max_pages: int = 8
) -> List[int]:
    """
    三步防幻觉:
    1. 剔除LLM凭空捏造的页码
    2. 如果有效页码不足min_pages,从检索结果中补充
    3. 限制最多max_pages,防止堆砌
    """
    retrieved_page_set = {r['page'] for r in retrieval_results}
    
    # Step 1: 过滤幻觉页码
    valid_pages = [p for p in claimed_pages if p in retrieved_page_set]
    hallucinated = set(claimed_pages) - set(valid_pages)
    if hallucinated:
        print(f"⚠️  Hallucinated pages removed: {hallucinated}")
    
    # Step 2: 补充页码(保证最少引用数量,满足竞赛/业务要求)
    if len(valid_pages) < min_pages:
        existing = set(valid_pages)
        for result in retrieval_results:
            if result['page'] not in existing:
                valid_pages.append(result['page'])
                existing.add(result['page'])
                if len(valid_pages) >= min_pages:
                    break
    
    return valid_pages[:max_pages]

系统架构流程图



关键配置演进与消融实验总结

作者在pipeline.py中保留了完整的配置演进历史,这本身就是一份教科书级的消融实验记录:

配置 核心增量特性 备注
base_config 基础vDB + SO CoT GPT-4o-mini
parent_document_retrieval_config + Parent Document Retrieval GPT-4o
max_config + 表格序列化 + LLM Reranking 意外:seq_tab hurt performance
max_no_ser_tab_config 去掉表格序列化 GPT-4o
max_nst_o3m_config 换用 o3-mini 最终冠军配置
ibm_llama70b_config 换用开源Llama 70b IBM WatsonX,与o3-mini仅差几分
gemini_thinking_config Full Context模式 不用检索,直接喂整份报告

基座模型

版本 模型 特点
最优配置(v4) o3-mini-2025-01-31 最高分,强推理
性价比配置(v0) gpt-4o-mini-2024-07-18 成本低,速度快
开源配置(v6) <font style="color:#DF2A3F;">Llama-3.3-70b</font>(IBM WatsonX) 仅比 o3-mini 低几分
超小模型(v7) <font style="color:#DF2A3F;">Llama-3.1-8b</font> 仍超 80% 参赛者
全文检索配置(v8/9) Gemini-2.0-flash-thinking 百万 Token 长上下文,无 RAG 直接塞全文

最反直觉的三个实验结论:

  1. 表格序列化反而有害(花了大量工程投入,最终不用)
  2. BM25混合检索反而有害(向量检索已经够好)
  3. Llama 8b + 这套pipeline 胜过80%参赛者(pipeline > model)

可迁移的核心设计原则

"RAG的魔法在细节里" ------ Ilya Rice

  1. 隔离索引(Isolation First):能per-entity建索引就不要混在一起。搜索空间越小,精度越高,成本越低。
  2. Chunk是指针,Page是内容(Pointer-Content Separation):用小块找位置,用大块给上下文。这是Parent Document Retrieval的本质。
  3. 路由胜过堆规则(Route Over Rules):与其把100条规则塞进一个prompt,不如拆成10个专用prompt各管10条。LLM的注意力是有限的稀缺资源。
  4. 验证闭环(Validation Loop):任何LLM输出都要做后处理验证(页码验证、Pydantic schema验证)。不要盲目信任LLM的格式和引用。
  5. 成本意识(Cost-Aware Design):LLM Reranking < 0.01/题 vs 全量扫描 0.25/题。好的系统设计天然就是经济的。
  6. 实验驱动,反直觉的结果才是最宝贵的(Empirical > Intuitive) :表格序列化这个"精心设计的大工程"最终被抛弃。测量胜过猜测。
相关推荐
AlfredZhao2 个月前
RAG 时代的“破壁人”:为什么你的大模型应用急需 Docling?
ai·rag·docling
我不是小upper10 个月前
PDF转Markdown基准测试
图像处理·人工智能·markdown·marker·docling