RAG 冠军项目学习分解~更新中(后续更新代码细节)

最近我系统学习了一个企业知识库 RAG 项目实战。最开始我以为 RAG 就是“把 PDF 扔进向量数据库,然后提问”,但真正结合开源项目代码一路看下来,我发现自己之前对 RAG 的理解其实非常浅。

老规矩先看定义:

RAG(Retrieval-Augmented Generation,检索增强生成)
指一种问答/生成方法:在大模型生成答案之前,先从外部知识库中检索相关信息,再把这些检索结果作为上下文提供给大模型,由大模型基于这些外部证据生成答案。

项目地址:https://github.com/llyaRice/RAG-Challenge-2
比赛网址: https://github.com/trustbit/enterprise-rag-challenge/tree/main

这次学习对我最大的帮助,不是记住了几个名词,而是终于把一条完整的链路串起来了:

PDF 解析 -> 文本整理 -> 分块 -> 向量化 -> 检索 -> 重排 -> 上下文增强 -> LLM 回答 -> 引用页校验

更重要的是,我开始理解:RAG 的本质不是“接一个大模型”,而是“把对的证据,以对的形式,在对的时机交给模型”

一、为什么这个项目值得学

这个项目参考的是企业 RAG 比赛冠军方案。主线非常清楚:系统围绕公司年报问答展开,重点包括:

  • 基础 RAG 系统流程
  • PDF 解析与 Docling 优化
  • 内容提取(Ingestion)
  • 检索(Retrieval)
  • LLM 重排序(LLM reranking)
  • 父页面检索(Parent Page Retrieval)
  • 结构化输出与 Prompt 设计

这是一个“从理论到工程”的完整案例;从代码视角看,它不是教程型 Demo,而是真正为了比赛成绩不断做优化的工程型项目。

也正因为如此,它特别适合用来回答一个问题:

RAG 在真实项目里,到底是怎么跑起来的?

二、RAG

我之前对 RAG 的理解大概是这样的:

  1. 把文档转成文本
  2. 切块
  3. 做 embedding
  4. 放进向量数据库
  5. 问题来了就检索几个 chunk
  6. 拼给 LLM 回答

这个理解不能说错,但太“平面化”了。真正看完项目以后,我发现至少有 5 个问题必须想清楚,否则对 RAG 的理解是不完整的。

1. 为什么要分块,不能直接整篇检索?

一开始我觉得,整篇文档不是信息更全吗?为什么还要切碎?

后来我明白了,分块的核心不是为了“切”,而是为了提高检索粒度

如果整篇文档直接做向量表示,会有两个问题:

  • 一篇文档里混着很多主题,语义被稀释
  • 即使检索到了,也无法把整篇文档高效交给模型

所以 chunk 的作用,是把“检索单元”变小,让系统更容易找到真正相关的证据。

换句话说:

分块不是为了拆文档,而是为了让检索变精准。

2. 为什么 chunk 要保留 page?

我之前觉得,保留 page 只是为了“以后好找原文”。

后来结合项目代码和比赛要求,我发现它至少有三层意义:

  • 为了溯源,能回到原始页面
  • 为了给答案附引用页码
  • 为了降低幻觉,方便人工复核

在企业知识库、财报问答这类场景里,“答案对不对”还不够,还得回答:

这个答案是从哪一页来的?

所以 metadata 不是可有可无的附属品,而是 RAG 系统可信性的基础。

3. 向量检索和 rerank 分别解决什么问题?

这是我之前理解得最模糊的一点。

现在我更愿意这样理解:

  • 向量检索解决召回问题
    先从海量 chunk 里找出“可能相关”的候选集合
  • rerank 解决排序问题
    再从候选集合里把“最适合回答问题”的内容排到前面

也就是说:

  • 向量检索像海选
  • rerank 像复试

需要反复强调了一点:检索不是一次性完成的,而是分阶段完成的

先“找得到”,再“排得准”。

4. 为什么 prompt 里要强制结构化输出?

结构化输出真正解决的是系统可用性

因为在企业问答里,系统不只是要一句自然语言回答,它还要稳定输出:

  • 最终答案
  • 推理摘要
  • 相关页码
  • 详细分析
  • 必要时返回 N/A

如果没有结构化输出,模型每次回答风格都不一样,后处理、评测、引用校验都会变得很麻烦。

所以这里的 prompt 设计不是“锦上添花”,而是“工程必需”。

5. base -> pdr -> rerank 这几个版本到底在提升什么?

  • base:基础向量检索版
  • pdr:Parent Document Retrieval,也就是父页面/父文档检索
  • rerank:在召回结果上再做重排

它们各自提升的是不同层面:

  • base 解决“能不能先找到相关内容”
  • pdr 解决“给模型的上下文够不够完整”
  • rerank 解决“候选内容里谁应该排在最前面”

所以这几个版本不是简单叠加功能,而是在不断修正一个问题:

如何把最有用、最完整、最可信的上下文交给 LLM。

三、项目的真实技术链路

结合课程内容和我阅读的 RAG-cy 代码,这个项目的实际流程可以概括成下面这条线:

在这里插入图片描述
@来源~陈博士大模型开发课程(知乎)

PDF -> Markdown -> chunk JSON -> embedding -> FAISS -> retrieval_results -> rag_context -> LLM

具体来说:

  1. 先把 PDF 转成 Markdown
  2. 再把 Markdown 按行切成 chunk,保存为 JSON
  3. 每个 chunk 的 text 做 embedding,建立向量库
  4. 问题来了以后,问题本身也做 embedding
  5. 在 FAISS 里找到最相近的 chunk
  6. 取回这些 chunk 对应的文本
  7. 拼成 rag_context
  8. 再把 rag_context + question 一起发给 LLM

其中还涉及两个路由Routing(虚线):

第一个路由是根据问题从不同的PDF文档中选出有关的向量库,进而提高检索效率。

第二个路由是,针对不同性质的问题,给LLM的提示词是不一样的,类似于分诊台的想法,提高模型的准确性

四、关键工程点

1. Parsing (数据预处理的重要性)

PDF 解析可以说是重要的地基:表格、多栏、页眉页脚、图片、公式、旋转表格等等。

但现在我更认同一句话:

垃圾解析结果,后面不可能靠检索补回来。

如果 PDF 转出来的内容混乱、表格结构丢失、页码不准,那么:

  • chunk 会切错
  • 检索会召回错误内容
  • 模型就只能基于错误上下文作答

也就是说,RAG 的上限,很多时候取决于 Parsing。

################################################################

PDF
-> Docling 解析
-> 01_parsed_reports
-> 页级清洗/合并
-> 02_merged_reports
-> 导出 Markdown
-> 03_reports_markdown
-> 按页分 chunk
-> databases/chunked_reports
-> embedding + FAISS

#################################################################

真正的复杂预处理,不是:PDF -> 一大段文字 -> chunk 而是:

PDF -> 页面里的结构块 -> 项目自己的结构化 JSON -> 页级语义重组 -> chunk

我们只看原版项目里第一页这一小段内容:

Securities registered pursuant to Section 12(b) of the Act:

| Title of each class               | Trading symbol(s) | Name of each exchange on which registered |
| Common Stock, par value $0.0001   | HLLY              | New York Stock Exchange                   |
| Warrants to Purchase Common Stock | HLLY WS           | New York Stock Exchange                   |

你可以把它理解成“PDF里的一段标题 + 一张表”。

1. 在 PDF 里,人眼看到的大概是这样

Securities registered pursuant to Section 12(b) of the Act:

Title of each class                 Trading symbol(s)   Name of each exchange on which registered
Common Stock, par value $0.0001     HLLY                New York Stock Exchange
Warrants to Purchase Common Stock   HLLY WS             New York Stock Exchange

这时候对人来说很清楚,但对程序来说还不够,因为程序还不知道:

  • 哪一行是标题
  • 哪一块是表格
  • 表格有几行几列
  • 这段在第几页

2. Docling 原始输出后,变成“结构块 + 引用”
这时候它不是一大段文字了,而是这种风格:

{
  "body": {
    "children": [
      {"$ref": "#/texts/21"},
      {"$ref": "#/tables/0"},
      {"$ref": "#/texts/22"}
    ]
  }
}

意思是:

  • 先出现一个文本块 texts/21
  • 接着出现一张表 tables/0
  • 然后再出现下面的正文 texts/22

而 texts/21 本身长这样:

{
  "self_ref": "#/texts/21",
  "label": "section_header",
  "text": "Securities registered pursuant to Section 12(b) of the Act:"
}

tables/0 本身不是纯文本,而是结构化表格:

{
  "self_ref": "#/tables/0",
  "data": {
    "num_rows": 3,
    "num_cols": 3,
    "grid": [
      ["Title of each class", "Trading symbol(s)", "Name of each exchange on which registered"],
      ["Common Stock, par value $0.0001", "HLLY", "New York Stock Exchange"],
      ["Warrants to Purchase Common Stock", "HLLY WS", "New York Stock Exchange"]
    ]
  }
}

这一步最关键的变化是:

程序已经知道“这是一个标题块 + 一张表”,不是普通连续文本。


3. 项目自己的结构化 JSON,会把页面整理成“按顺序的块列表”
到了项目的 01_parsed_reports,同一段会变成这样:

{
  "page": 1,
  "content": [
    {
      "text": "Securities registered pursuant to Section 12(b) of the Act:",
      "type": "section_header",
      "text_id": 21
    },
    {
      "type": "table",
      "table_id": 0
    },
    {
      "text": "Indicate by check mark if the registrant is a well-known seasoned issuer...",
      "type": "text",
      "text_id": 22
    }
  ]
}

注意这里:

  • 标题保留了
  • 表格位置也保留了
  • 但表格正文还没直接塞进页面文本
  • 页面里现在只是一个 table_id: 0

与此同时,表格单独保存成:

{
  "table_id": 0,
  "page": 1,
  "markdown": "| Title of each class | Trading symbol(s) | Name of each exchange on which registered |\n|---|---|---|\n| Common Stock, par value $0.0001 | HLLY | New York Stock Exchange |\n| Warrants to Purchase Common Stock | HLLY WS | New York Stock Exchange |"
}

这一步你要抓住一句话:

页面里先放“表格占位符”,真正表格内容单独存。


4. 页级清洗后,才真正变成适合 RAG 的文本
到了 02_merged_reports,这一段会被重写成:

## Securities registered pursuant to Section 12(b) of the Act:

| Title of each class               | Trading symbol(s)   | Name of each exchange on which registered   |
|-----------------------------------|---------------------|---------------------------------------------|
| Common Stock, par value $0.0001   | HLLY                | New York Stock Exchange                     |
| Warrants to Purchase Common Stock | HLLY WS             | New York Stock Exchange                     |

你看见没有,这里发生了真正重要的事:

  • section_header 被转成了 ##
  • table_id = 0 不见了
  • 表格真实内容被回填进来了
  • 现在这一页已经变成“适合检索”的干净文本了

这一步就是最核心的数据预处理。不是抽字,而是:把结构块重新组织成语义更清晰的页文本。


5. 最后再切成 chunk
到了最终给向量库的 chunk,大概会变成这样:

{
  "page": 1,
  "text": "## Securities registered pursuant to Section 12(b) of the Act:"
}

下一个 chunk:

{
  "page": 1,
  "text": "| Title of each class | Trading symbol(s) | Name of each exchange on which registered |\n|---|---|---|\n| Common Stock, par value $0.0001 | HLLY | New York Stock Exchange |\n| Warrants to Purchase Common Stock | HLLY WS | New York Stock Exchange |"
}

再下一个 chunk 可能就是表格后面的正文。

所以最后送去 embedding 的不是原 PDF,也不是表格坐标,而是:

  • 干净的标题文本
  • 干净的表格文本
  • 干净的正文文本
  • 且保留页码

你现在只要记住这个最小例子就够了

原始 PDF 里的:

标题 + 表格 

先变成:

标题块 + 表格引用 

再变成:

页面块列表 + 表格单独实体 

再变成:

标题 + 真正表格文本 

最后变成:

page=1 的多个 chunk 

最短总结就是:

复杂预处理的本质,是先识别结构,再把结构重写成适合检索的文本。

如果你愿意,我下一条继续用这种方式,直接把“列表”再举一个真实例子给你看,比如:

一段正文 + 多个 bullet list
是怎么从 Docling 变成最终 RAG chunk 的。

################################################################

2. 表格是 RAG 里最容易被低估的问题

大型表格往往会削弱 chunk 的语义连贯性。

原因很简单:

  • 横向表头和纵向内容离得很远
  • 一个表格未必能完整塞进一个 chunk
  • 就算塞进了,模型也未必能正确理解列和行的对应关系

表格序列化理论上很有希望,但在实际测试里未必真的提升效果。

一个很实用的经验是:

  • 对人和 LLM 都友好的表格格式,Markdown 是一个方案
  • 但在复杂表格、合并单元格场景里,HTML 往往更强

这也是为什么我现在不会再简单地说“把表格转成文本就行”。

3. Metadata (用于PDR)

metadata 是 text 以外的重要信息,比如 page。

RAG 系统里常见的 metadata 包括:

  • 页码
  • 文档名
  • 公司名
  • 时间
  • 章节号
  • 文档类型

这些信息不仅服务于溯源,也能参与路由、过滤和答案生成。

一个真正能落地的企业知识库系统,绝不是只有“文本向量”这一种信息。

4. Small-to-Big 思路

项目里的 Parent Document Retrieval(PDR) 实际是一种 small-to-big思想。

它的思想很简单:

  • 先用小粒度内容召回
  • 再扩展到更完整、更大的上下文

为什么这样做有效?

因为小 chunk 更容易精准命中,但小 chunk 往往上下文不完整;而大页内容更完整,但直接检索又不够精细。所以真正好的做法往往不是二选一,而是:用小粒度检索,用大粒度回答。

5、BM25

项目里有 BM25Ingestor 和 BM25Retriever,所以实际上采用了“关键词检索 + 向量检索”的混合搜索。

因为从经验上看,混合检索通常更强:

  • BM25 擅长精确关键词命中
  • 向量检索擅长语义召回
  • 再加 rerank,效果往往更好

但我实际看完 RAG-cy 以后发现,这版中文改造流程里:

  • 主流程没有真正接入 BM25
  • 问答时实际用的是 VectorRetriever 或 HybridRetriever
  • 这里的 HybridRetriever 指的是“向量检索 + LLM 重排”,不是“向量 + BM25”

更有意思的是,这版 BM25 对中文的分词方式还是简单 split(),这对中文其实并不友好。

五、总结

经过这次学习,我对 RAG 的理解发生了一个明显变化。

我以前理解的是:

RAG = 检索 + 生成

我现在更愿意理解为:

RAG = 围绕“证据质量”展开的一整套系统设计

它至少包括:

  • 文档能不能被正确解析
  • chunk 怎么切
  • metadata 怎么保留
  • 用什么方式检索
  • 是否需要 rerank
  • 是否需要 small-to-big
  • 最终怎么把上下文拼给模型
  • 输出是否结构化
  • 能不能回溯到引用页码

也就是说,RAG 的难点从来不只是“接一个向量库”。

真正难的是:

怎样让模型基于可靠证据回答,而不是基于模糊印象回答。

六、一些问题与思考

Q:冠军模型用的LLM 的reranking 模型是什么?

o3-mini,通过prompt的方式让它扮演 reranking的专家

Q:为什么要用基于LLM的重排序?

由于query和pdf的文字内容都是文字,而大模型是在语言空间内进行重排序,这样比传统的用query 与 向量数据库之间的相似度计算更准确,但速度会比较慢;。

Q:Top-K 大于多少阈值才需要重排序?太小就不需要重排序了吧

Top-K 一般要大于30,因为在分chunk一般如果想精细化会在500token,放入一个chunk,表格和图片orc后的另说。
30个chunk*500token=15000

Q:拓展可以在哪里进行?

agentic下工具调用,MLLM,多轮对话

Q:那切分时表格必须在一个切分块吧?

建议放到一个chunk(可能这个chunk >500,比如不超过 20%的buffer,如果表格 <=600,可以用完整的一个)

1200token => 拆分成2个表格(表头)

Q:不切分表格或者图片会咋样?有一些不切分是不是效果更好?比如一个人一份简历就是完整检索召回不会效果更好吗?

如果想要效果好,可以做一些分隔符标记,比如 ====,具体案例具体分析。

Q:业界会用按照章节号和chunk size结合方式来切分吗?

大纲,有章节号,可以直接用章节号进行切分
可以让LLM进行语义切分,chunk size是软规则,建议 chunk_size不要超过500

Q:那切分时表格必须在一个切分块吧

以及标书空白页、格式等20项检查。有什么好的方案实现

给示例,让LLM来检查,人工先标注做为种子示例,让LLM对其他的文档进行标注

Q:切片是算法工程师做吗?还是产品+算法工程师

chunk 可以让产品经理,也可以让算法工程师来进行设计

Q:将一本书的PDF放进去解析,太大了,如何操作

MinerU,1000页
1-100页,100-200页拆分分别处理

Q:有图像时,与文字的向量空间怎么对齐

1)都转化成文字的向量空间
可以利用 Qwen-VL 对图像进行语义理解 => 输出文本

2)图embedding(vec_size=768), 就不能与文字向量(vec_size=1024)进行匹配,需要进行size对齐,但会影响图片质量,建议第一种,目前多模态空间还是有更大的幻觉问题

Q:要给客户,构建知识库 =>

方法1:就用原始数据 => 向量数据库
方法2:对原始数据进行整理,summary
level1:摘要内容
level2:完整的全文

small-to-big
small:摘要
big:全文

当用户提问一个问题的时候,我们可以从 level1的角度进行召回,如果想要回答的更细,可以给LLM,level2数据
CoT, Step by Step

七、如果我要自己搭一个企业知识库,我会重点盯这几件事

如果让我自己做一个企业知识库 RAG,我会优先关注:

  • 先把 Parsing 做对,尤其是表格和页码
  • chunk 设计不要只盯 token 数,要结合章节、页面和业务结构
  • metadata 一定要保留
  • 先做基础向量检索,再考虑 rerank
  • 如果回答需要完整上下文,优先考虑 small-to-big 或 parent page retrieval
  • 输出一定要结构化,方便评测、调试和溯源
  • 不要迷信“理论上更强”的模块,必须做实验

八、这次学习后,我给自己的一个复习框架

如果以后我再复习 RAG,我会反复问自己下面这几个问题:

  1. 文档是怎么从 PDF 变成可检索文本的?
  2. chunk 为什么要这样切?
  3. 向量检索召回了什么?有没有漏掉关键内容?
  4. rerank 到底排的是什么?
  5. 最终喂给 LLM 的上下文是什么样子?
  6. 输出为什么必须结构化?
  7. 这个系统如何证明答案来自原文?

如果这 7 个问题都能回答清楚,我觉得才算是真的把一个 RAG 项目学懂了。

结语

这次最大的收获,不是我学会了几个新模块,而是我对 RAG 的认知从“工具调用”变成了“系统设计”。

RAG 不是一句“接知识库”就能概括的东西,它是一条完整的数据流和证据流。

从课程、笔记到代码,我现在越来越认同一句话:

大模型决定回答上限,检索系统决定回答下限。

而一个好的 RAG 系统,真正做的事情就是:

尽可能把下限抬高。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐