跳到主要内容

把十几种格式收成一种东西 — 加载器的合同,以及粒度是在这一步被定死的

这一章讲三件事: 十几种格式怎么被收成同一种东西;这份「合同」没管的那件要命的事 (一个文件到底切成几个 Document);以及书里当最佳实践教的一条有害操作。 它在全书链条里的位置: 这是六站里的第一站。后面每一站的输入,都是这一站的输出 —— 这一站定死的粒度和漏挂的标签,后面五站都补不回来。

1. 顶层全景

PDF ──┐
Word ─┤
CSV ──┼──► 加载器(每种格式一个)──► List[Document] ──► 后面五站
Excel ┤ │
JSON ─┤ ├─ page_content:一段正文
网页 ─┘ └─ metadata :一小袋标签

图说:左边有多少种格式,就有多少个加载器;而右边永远只有一种东西。
「换格式只换左边那一个,右边一行不改」——这就是这一站的全部意义。

但这张图藏了一件事:那个箭头上没写「几个」。 同一份数据,有的加载器给你四个 Document,有的给你一个。这就是本章真正的内容。

2. 先看现象:同一批人员数据,在两种文件里长什么样

这一节只摆现象,先不给结论。

书里有两份人员表,一份是 CSV 文件,一份是 Excel 文件。先看它们读进来之后各是什么样子。

CSV 那一份

输入文件里是一张四行的表,表头是 Name,Department,Role,Location,Joining Date, 第一行是 Anil Sharma1

读进来之后,程序打印出四段,第一段长这样 —— 注意它把表格的每一列都单独写成了一行 「列名: 值」2:

--- Document 1 ---
Name: Anil Sharma
Department: Engineering
Role: Software Engineer
Location: Bangalore
Joining Date: 10/01/2022

图说:一行数据变成了一个 Document,而且正文被摊成了五行「列名: 值」。
另外三行各变成 Document 2、3、4。

Excel 那一份

输入是一张三行的表,表头是 ID Name Department Age,第三行是 Rajeev Kumar,42 岁3

读进来之后,程序只打印出一段4:

--- Document 1 ---
ID Name Department Age 1 Jatin Kumar HR 34 2 Abhishek Kumar IT 28 3 Rajeev Kumar Finance 42

图说:整张表被压成了一行没有任何分隔的字串。行没了,列没了,连表头和数据的界限都没了。

先记住这两张截然不同的样子。 下面三节分别回答:这是怎么做到的、为什么差这么多、这个差别会要谁的命。

接缝提示(照实说): 这两份表不是同一批数据 —— CSV 那份是四行五列的 Anil/Raj/Neha/Ashok, Excel 那份是三行四列的 Jatin/Abhishek/Rajeev,连表头都不一样,书没有解释为什么换。 我们把它们并排放,是为了看粒度的差别,而这个差别和行数列数无关。

3. 这份合同长什么样:读文件 → 解析 → 交出一串 Document

它解决什么问题

假设没有这一站。那么后面每一步都得写成这样:

如果是 PDF: 按页取文字,再处理换行……
如果是 CSV: 按逗号切,第一行是表头……
如果是 Excel:打开工作簿,遍历工作表,遍历行……
如果是网页: 下载,剥标签,去脚本……

图说:这还只是第一步。切块、嵌入、入库、检索、生成——每一步都要重复这一坨。
十种格式 × 六站 = 六十份代码。

所以要在最早的地方把分叉收掉。

它怎么做

书把这件事定义成一个接口(前面说过,接口就是「你按约定给我输入、我按约定给你输出, 内部怎么做你不用管」)。这份约定只有三句话5:

读文件或网页 → 解析成文本 → 返回一串 Document,每个带正文和元数据。

于是每种格式各写一个加载器去满足这份约定,而下游只认 Document

为什么这么做有效

因为「换掉哪一行」这件事变得可以指着说了。

loader = CSVLoader("employees.csv") ← 想换格式,只改这一行
documents = loader.load() ← 这一行不动
# ……后面五站全部不动

书里十二个加载配方,结构一模一样,只有第一行不同: CSVLoader / UnstructuredExcelLoader / BSHTMLLoader / JSONLoader / WebBaseLoader…… 这份重复本身就是证据 —— 合同确实兑现了。

边界在哪

合同管了「交出来的是什么」,没管「交出来几个」。

这就是第 2 节那两张截然不同的样子的来源,也是下一节的全部内容。

4. 主走查:一张人员表,两条路,粒度差四倍

这一节是本章的主走查。上面那份合同、下面那袋标签,都在这条线上占一步。

走 CSV 这条路走 Excel 这条路
① 换加载器CSVLoader("sample_csv_file.csv")UnstructuredExcelLoader("sample_excel_file.xlsx")
② 下游代码loader.load() —— 两条路完全一样,一个字没改同左
③ 交出几个 Document4 个(输入 4 行)1 个(输入 3 行)
④ 第一个 Document 的正文Name: Anil Sharma\nDepartment: Engineering\nRole: Software Engineer\n…ID Name Department Age 1 Jatin Kumar HR 34 2 Abhishek Kumar IT 28 3 Rajeev Kumar Finance 42
⑤ 挂上的标签书这一处没印元数据书这一处也没印
⑥ 到第 ② 站(切块)手里的是什么4 段短文本,每段讲一个人1 段长文本,讲三个人
⑦ 到第 ③ 站(变成一串数)之后4 串数,每串代表一个人1 串数,代表「三个人混在一起」
⑧ 问「Rajeev 多少岁」会怎样(这份表里没有 Rajeev)只可能命中那唯一一个 Document,而它把三个人的年龄摆在一起,谁是谁全靠模型猜

第 ⑦ 步是全章的要害。

第 01 章说过,把一段文字变成一串数,是把这段文字的整体意思压成一个点。 一段文字里如果混着三个人的三条记录,压出来的那个点谁都不像—— 它既不在「Jatin 是 HR」附近,也不在「Rajeev 42 岁」附近,而在三者的平均位置上。

于是「Rajeev 多少岁」这个问题,连找都找不准;就算找到了,给模型的也是一整张表。

顺手看第三条路:JSON

书里读 JSON 用的写法是 JSONLoader(jq_schema=".")。这个点号的意思是「整个文件当作一个整体取出来」。

输入是三条人员记录的数组,输出是一个 Document,正文长这样6:

Content: [{"id": 1, "name": "Pankaj Kumar", "email": "pankaj@example.com",
"interests": ["reading", "hiking", "cooking"]}, {"id": 2, …}, {"id": 3, …}]

图说:三条记录挤成一行。而书里那句说明写的是「每个 document 都会有正文和元数据属性」——
读者会以为是每条记录一个 document。**书里没有提 `.[]` 这个「逐条取出」的写法。**

同一个坑,第三次出现。

这一站定死的东西,后面补不回来

这一站定死了什么后面还能改吗
一个文件切成几个 Document能再切细,但合并不回去 —— Excel 那条路上,行列关系已经在字串里丢了
正文长什么样(比如 CSV 被渲染成「列名: 值」)能改,但要自己写代码把加载器的输出再洗一遍
挂了哪些标签不能 —— 文件已经读完了,「它原来在第几页」这个信息不在正文里

判断(我们的,不是书里的): 这一章真正该给的选型建议是一句话: 表格类数据要一行一个 Document,除非你打算问的是「整张表汇总起来怎样」。 书给了两种加载器,却没有给这个判据 —— 它只说 CSV 加载器「每个 document 对应一行」, Excel 加载器「每个 document 对应一个工作表」,像在陈述两个中性的事实。 如果错,会错在: 如果你的问题本来就是整表级的(「这个部门一共几个人」), 那么一行一个反而更糟 —— 因为答案要跨多个 Document 才凑得齐,而检索一次只取前几条。 判据是:你要问的问题,答案落在一行里还是落在整张表上。

5. 元数据:这一站是唯一能挂标签的时机

现象:同样是读一个文件,有的加载器给你一堆标签,有的一个都不给

书里读网页的那个配方,打印出来的标签是7:

Metadata: {'source': <那个网页的地址>, 'title': 'Example Domain',
'language': 'No language found.'}

图说:三个键都是加载器自己填的。书里用的是那个专门留给举例用的示例域名。

而读纯文本的那个配方,标签得自己手工塞8:

doc.metadata["source"] = "local_file"
doc.metadata["category"] = "tutorial"
doc.metadata["author"] = "Deepak"

→ Metadata: {'source': 'local_file', 'category': 'tutorial', 'author': 'Deepak'}

图说:三个键是作者自己定的。加载器不会替你想「这份文件属于哪一类、谁写的」。

为什么必须在这一站挂

因为文件一旦被读成字符串,它的出身就没了。

  • 「这段话在原文件的第几页」—— PDF 加载器读的时候知道,读完就不知道了;
  • 「这份文件是谁上传的、什么时候的」—— 只有你在打开它的那一刻知道;
  • 「这条记录属于哪个部门」—— 在 CSV 的某一列里,而正文渲染完之后它只是一行字。

它换来什么

两件事,后面各占一整节:

用途在哪一章兑现
过滤:只在符合条件的文档里搜第 05 章第 9 节(两种过滤顺序的取舍)
追溯:答案里那句话是从哪份文件来的第 08 章(引用是工程问题,不是提示问题)

书给元数据的定位就是这两件事,一句话说完,再没展开9

6. 另起一处:书当最佳实践教的一条有害操作

这是本章唯一一处需要我们纠正书的地方,所以单独走一遍。

书教了什么

有一个配方叫「加载时做预处理」,做三件事:全部转小写、删掉一类词、把连续空白压成一个。

被删掉的那类词叫停用词:就是 the / is / of / and / not 这种在英文里到处都是的词。 书给的理由写在代码注释里:「过滤掉那些对意思没有贡献的常见词」10

它的输出长什么样

原文是那句 RAG 定义句:

Retrieval Augmented Generation (RAG) is an architecture that combines the ability of
large language models (LLMs) with a retrieval system to enhance the factual accuracy,
contextual relevance, and quality of generated response against the query raised by
user to a RAG system.

洗完之后,书印出来的是11:

retrieval augmented generation (rag) architecture combines ability large language
models (llms) retrieval system enhance factual accuracy, contextual relevance,
quality generated response query raised user rag system.

图说:is / an / that / the / of / with / a / to / and / against / by 全部被删。
这已经不是一个英文句子了——它是一串词。

为什么这是有害的

因为这套操作是给另一条路准备的,而这本书用的是这条路。

两条路它怎么判断「像不像」去停用词有没有用
按词面找(第 06 章讲的 BM25 那一路)数两边有多少个词重合有用 —— the / of 到处都是,留着只会稀释信号
按意思找(这本书从头用到尾的那一路)把整句话压成一串数,再比远近有害 —— 压那一步是在完整的自然句子上训出来的

关键在于第二行的最后一句。 把句子压成一串数的那个模型, 是拿大量完整的句子对训出来的:训练时把「该像的一对」拉近、把其余的推远12。 它见过的输入全是正常的句子;你喂给它一串被抽掉虚词的词, 那是它训练时从没见过的东西 —— 它压出来的点落在哪里,没人保证。

更直接的伤害:虚词里有一部分根本不是虚的。 notwithoutbetweenbefore 都在通用的停用词清单里。 删掉 not,一句话的意思会当场反过来。

生产系统实际怎么做

不是二选一,是两份文本各走各的路。

我们书架上那套公开了源码的检索系统,做法是把查询另外洗一份出来专门交给按词面找的那一路, 原始查询照旧交给按意思找的那一路 —— 两路各用各的文本,谁也不动谁13

判断(我们的,不是书里的): 这一处不是「书讲得浅」,是书讲反了。 依据有两条:① 这个配方明写在「加载时预处理」这一节里,也就是说洗完的文本是要入库的; ② 全书入库用的一律是那种把整句压成一串数的模型,没有一处用按词面找的那一路做主检索。 如果错,会错在: 如果作者的本意只是「演示 nltk 这个库怎么调」、并不建议真的这么洗, 那这就是表述问题而不是建议问题。但这一节的标题是「预处理与文本规范化」, 正文写的是「让内容准备好被嵌入」,读者只会照做。

7. 一整个目录怎么灌进去

单个文件讲完了,真实场景是一个文件夹几百份文件。书给了两个配方,各解决一件事。

第一件:一次读一堆,并且允许有几份读失败

配方 24 做三件事:按扩展名分发到对应的加载器、开四个线程同时读、把失败的那份单独捞出来:

supported_exts = {".pdf", ".txt", ".docx"} ← 不认的扩展名直接跳过
ThreadPoolExecutor(max_workers=4) ← 四份同时读
except Exception as e:
print(f"Failed to load {…}: {e}") ← 坏的那份只打一行日志,不中断整批

输出:Loaded 3 documents.

图说:三个文件读出三个 Document。最后那个 except 是这段代码里最值钱的三行——
一份坏文件不该让整批灌入停下来。

书没有讲这一点,只在代码里写了出来14。而这恰恰是「一次把整个目录读进来」最要紧的设计: 你不会因为第 197 份 PDF 是加密的,就让前面 196 份白读。

第二件:小文件先攒成堆,再切

配方 25 演示了一个反直觉的顺序:三个小文件先合成一个逻辑包,再切成块15:

1) Loaded 3 small documents ← 三个小文件
2) Grouped into 1 logical batches ← 按「一共不超过 300 个标记」攒成一堆
3) Split into 6 chunks ready for embedding ← 再统一切成 6 块

图说:三个文件变成一个包,再变成六块。为什么不直接切?见下。

为什么要多这一道: 一个只有两行字的小文件,单独切出来就是一个两行字的块。 这样的块上下文太少,嵌进去之后谁都不像 —— 第 04 章会看到, 块越碎、块与块之间越不相似。先攒堆,是给每一块凑够上下文。

8. 这份合同你自己也能实现

这是本章最实用的一条:格式不在清单里,不是死路。

书的最后一个配方演示了自定义加载器,总共十行:继承那个基类、实现一个 load() 方法、 返回一串 Document,合同就算履行了

它顺手演示了一件事:标签的值不一定是字符串,也可以是一个列表16:

{'source': 'RAG.txt', 'category': 'session_notes', 'author': 'Deepak',
'tags': ['custom', 'demo', 'loader']}

所以那些「我们的数据在内部系统里,没有现成的加载器」的情况,答案是:自己写一个。 只要交出来的是 Document,后面五站根本不关心它从哪来 —— 数据库、内部接口、私有格式都行。

9. 作者的判断与证据

书里给了证据的:

说法证据
加载器把各种格式变成同一种东西十二个配方结构一模一样,只有第一行不同
CSV 一行对应一个 Document配方 16 的输出:四行 → Document 1 到 Document 4
Excel 一个工作表对应一个 Document配方 17 的输出:三行 → 一个 Document,一行字串
网页加载器会自动带上来源与标题配方 20 打印出的那三个键
自定义加载器可行配方 26 的十行代码与输出

作者只是断言、没有给证据的:

说法缺什么
「删掉那些对意思没有贡献的常见词」这是错的,而且书没有做过任何「洗过 vs 没洗」的检索效果对比
JSON 那个配方说「每个 document 都会有正文和元数据属性」措辞暗示每条记录一个文档,而实际输出是整个数组一个
配方 24 说线程池能「提高效率」没说清为什么四个线程对这件事有效 —— 读文件时大部分时间在等磁盘,而真正占着处理器的解析那一半并没有变快
「按意思分组加载」那个配方输出和普通按段落读没有区别,只是标签里多了一串认不出的编号和一个文件修改时间

10. 边界与局限

  • 书没有一处讨论粒度该怎么选。 它把「一行一个」和「一整表一个」并列陈述,像两个中性的事实。
  • Excel 那条路的后果书完全没提,也没指向后面哪一章能救。(答案是:救不了,只能回来重读。)
  • JSON 只给了「整个文件一个」这一种写法,没有提逐条取出的写法,也没解释那个点号是按什么规则写的。
  • 扫描件、图片里的文字一个字没讲。 支持格式清单里列了「图片与扫描件」,而全书没有对应的配方。
  • 这一章有两处用了已经废弃两年的导入写法(from langchain.document_loaders import CSVLoader), 和同书其他配方的新写法打架17
  • 有一个配方的输入输出对不上: 声明的输入文件是 RAG_uncleaned.txt 且只有一段, 代码里读的却是 RAG.txt,而输出里出现了两段的内容。

11. 可带走的

全章那条走查,一行写完: 同一张人员表 → 换 CSV 加载器得 4 个 Document (正文是「Name: Anil Sharma / Department: Engineering / …」)→ 换 Excel 加载器得 1 个 Document (正文是「ID Name Department Age 1 Jatin Kumar HR 34 …」一行字串)→ 下游一行代码没改,而后面每一站拿到的东西已经差了四倍。

  1. 加载器的全部意义是一份合同: 读文件 → 解析 → 交出一串 Document;换格式只换一行;
  2. 合同管「交出来的是什么」,不管「交出来几个」 —— 粒度是每个加载器自己定的;
  3. 表格类数据默认要一行一个,除非你的问题本来就是整表级的;
  4. 粒度太粗的后果在第三站爆发: 三条记录压成一个点,谁都不像;
  5. 一整个数组挤成一个文档是 JSON 那个默认写法的行为,不是你想要的;
  6. 元数据只有这一站能挂,漏了后面补不回来;它换来的是「能过滤」和「能追溯」;
  7. 别在入库前把正文小写化、删停用词 —— 那是给按词面找的那一路准备的操作, 对按意思找的那一路有害;生产做法是两路各洗各的文本;
  8. 一次读整个目录时最值钱的是那个 except —— 一份坏文件不该让整批停下来;
  9. 小文件先攒成堆再切,免得切出一堆上下文太少的碎块;
  10. 格式不在清单里就自己写一个加载器 —— 只要交出 Document,后面五站不关心它从哪来。

12. 原文地图

主题原书章原文位置
Document / 元数据 / 加载器接口的定义CHAPTER 2 Document Loaders for RAG Pipelinestext/09-fm-introduction.txt:37(搜「fundamental data unit passed through a RAG pipeline」) · text/09-fm-introduction.txt:41(搜「implements a standard interface for ingesting external data」)
CSV:一行一个 Document同上text/09-fm-introduction.txt:265(搜「Name,Department,Role,Location,Joining Date」) · text/09-fm-introduction.txt:279(搜「Name: Anil Sharma」)
Excel:整表压成一行字串同上text/09-fm-introduction.txt:381(搜「ID Name Department Age」) · text/09-fm-introduction.txt:393(搜「1 Jatin Kumar HR 34」)
JSON:整个数组一个 Document同上text/09-fm-introduction.txt:661(搜「Content: [{"id": 1, "name": "Pankaj Kumar"」)
网页加载器自动带的标签同上text/09-fm-introduction.txt:731(搜「Example Domain」)
手工挂自定义标签同上text/09-fm-introduction.txt:827(搜「'category': 'tutorial', 'author': 'Deepak'」)
小写化 + 去停用词(有害)同上text/09-fm-introduction.txt:843(搜「filter out common words that do not contribute to」) · text/09-fm-introduction.txt:966(搜「retrieval augmented generation (rag) architecture combines ability」)
批量并行加载与失败兜底同上text/09-fm-introduction.txt:1186(搜「Failed to load」) · text/09-fm-introduction.txt:1250(搜「Loaded 3 documents.」)
先按标记数攒堆再切同上text/09-fm-introduction.txt:1416(搜「Loaded 3 small documents」) · text/09-fm-introduction.txt:1420(搜「Split into 6 chunks ready for embedding」)
自定义加载器同上text/09-fm-introduction.txt:1480(搜「return [Document(page_content=text, metadata=self.metadata)]」) · text/09-fm-introduction.txt:1542(搜「'tags': ['custom', 'demo', 'loader']」)
已废弃的导入写法同上text/09-fm-introduction.txt:229(搜「from langchain.document_loaders import CSVLoader」)

Footnotes

  1. 出处:「CHAPTER 2 Document Loaders for RAG Pipelines」第 265 段(text/09-fm-introduction.txt:265,搜「Name,Department,Role,Location,Joining Date」)。四行数据分别是 Anil Sharma、Raj Kumar、Neha Kohli、Ashok Singh。

  2. 出处:同章第 279 段(text/09-fm-introduction.txt:279,搜「Name: Anil Sharma」)。第四个 Document 印在第 315 段(text/09-fm-introduction.txt:315,搜「Name: Ashok Singh」),可见确实是四个。

  3. 出处:同章第 381 段(text/09-fm-introduction.txt:381,搜「ID Name Department Age」)—— 这是输入文件的内容,三行数据分别是 Jatin Kumar、Abhishek Kumar、Rajeev Kumar。

  4. 出处:同章第 393 段(text/09-fm-introduction.txt:393,搜「1 Jatin Kumar HR 34」)。代码里的注释写着「每个 document 对应 Excel 里的一个工作表」(text/09-fm-introduction.txt:369,搜「Each document corresponds to a sheet in the Excel file」)。

  5. 出处:同章第 41 段(text/09-fm-introduction.txt:41,搜「implements a standard interface for ingesting external data」)。原文的三步是:读文件或网页内容 → 解析成文本 → 返回 List [Document],带正文与元数据。

  6. 出处:同章第 661 段(text/09-fm-introduction.txt:661,搜「Content: [{"id": 1, "name": "Pankaj Kumar"」)。那句让人误会的说明在第 601 段(text/09-fm-introduction.txt:601,搜「Each document will have a page content and metadata attributes」)。

  7. 出处:同章第 731 段(text/09-fm-introduction.txt:731,搜「Example Domain」)。第三个键的值是 'No language found.' —— 这个网页没声明语言,加载器如实记了下来。

  8. 出处:同章第 827 段(text/09-fm-introduction.txt:827,搜「'category': 'tutorial', 'author': 'Deepak'」)。塞键的三行代码在第 791–795 段(text/09-fm-introduction.txt:793,搜「doc.metadata["category"] = "tutorial"」)。

  9. 出处:同章第 39 段(text/09-fm-introduction.txt:39,搜「contextual key-value data associated with a document」)与第 735 段(text/09-fm-introduction.txt:735,搜「effective filtering, source attribution, and content routing」)。后一处列的三种用途是:有效过滤、来源归属、多源系统里的内容路由。

  10. 出处:同章第 843 段(text/09-fm-introduction.txt:843,搜「filter out common words that do not contribute to」)。这一节的标题是「Preprocessing and text normalization」,正文说这一步是把原始文本「清洗、标准化,准备好去做嵌入」(text/09-fm-introduction.txt:831,搜「cleaned, standardized, and made ready for embedding」)。

  11. 出处:同章第 966 段(text/09-fm-introduction.txt:966,搜「retrieval augmented generation (rag) architecture combines ability」)。顺带一提,这个配方声明的输入文件是 RAG_uncleaned.txt 且只印了一段(text/09-fm-introduction.txt:952,搜「RAG_uncleaned.txt is the input file」),而代码里读的是 RAG.txt,输出里又出现了第二段的内容 —— 输入输出对不上。

  12. 补充(不在书里,依据我们的 frontier 书架):把句子压成一串数的那种模型,训练目标就是重塑向量空间 —— 把语义该近的一对拉近、把同一批里其余的推远;而没经过这种训练的骨干网络,它的输出空间里语义相近的句子并不相近。 依据: shelf=ai-frontier-reference/sentence-transformers#02-contrastive-training.md @652c8e8a70b9bef7161405ac7e6d8f3b2a828aed 事实=文档明写「预训练骨干的输出空间里,语义相近的句子并不相近。训练嵌入模型的任务就是重塑这个空间:拉近语义该近的对,推远其余一切」。

  13. 补充(不在书里,依据我们的 agent 书架):生产系统的做法是给两条检索路各准备一份文本,而不是把原文洗掉。 依据: shelf=ai-agent-reference/onyx#04-retrieval-and-citations.md @cbfd6b327b348beac532801306de63eed8551248 事实=检索流水线里 strip_stopwords(query) 单独产出一份 query_keywords 交给 BM25,原始查询照旧交给向量检索,两路各用各的文本。

  14. 出处:同章第 1186 段(text/09-fm-introduction.txt:1186,搜「Failed to load」)与第 1250 段(text/09-fm-introduction.txt:1250,搜「Loaded 3 documents.」)。顺带一提,这段代码对每个文件调了两次 get_loader(fp)(text/09-fm-introduction.txt:1170,搜「executor.submit(get_loader(fp).load): fp」),等于把加载器建了两遍。

  15. 出处:同章第 1416 段(text/09-fm-introduction.txt:1416,搜「Loaded 3 small documents」)与第 1420 段(text/09-fm-introduction.txt:1420,搜「Split into 6 chunks ready for embedding」)。攒堆的上限写在第 1390 段(text/09-fm-introduction.txt:1390,搜「max_tokens=300」)。

  16. 出处:同章第 1542 段(text/09-fm-introduction.txt:1542,搜「'tags': ['custom', 'demo', 'loader']」)。load() 方法的全部实现只有三行,末尾就是第 1480 段那句返回(text/09-fm-introduction.txt:1480,搜「return [Document(page_content=text, metadata=self.metadata)]」)。

  17. 出处:同章第 229 段(text/09-fm-introduction.txt:229,搜「from langchain.document_loaders import CSVLoader」)与第 1118 段(text/09-fm-introduction.txt:1118,搜「from langchain.document_loaders import TextLoader, PyPDFLoader」)。同一本书的其他配方用的是 langchain_community.document_loaders(text/09-fm-introduction.txt:349,搜「from langchain_community.document_loaders import UnstructuredExcelLoader」)。