Document 与加载:page_content / metadata / BaseLoader / lazy_load
阶段2 我们打通了 prompt | model | parser,但模型只知道训练时见过的东西。想让它回答"你自己文档里的问题",得走 RAG——第一步就是把 PDF、网页、数据库里的内容搬进一个统一容器。今天看 libs/core/langchain_core/documents/ 与 document_loaders/:①Document 为什么只有 page_content + metadata 两个主角;②BaseLoader 的搬运工协议;③为什么源码强调"实现 lazy_load 而不是 load"。
Document 是一张带卡片的书页——正面是内容本身(page_content),背面钉着一张索引卡片(metadata:来自哪本书、第几页、哪年出版)。检索系统翻的是卡片,读者读的是正文。类比二:BaseLoader 是图书馆的进书通道:load() 像"一卡车书一次全卸进大厅"(内存全占),lazy_load() 像"传送带一本一本送进来、上架一本再来一本"(恒定内存)——馆藏百万册时,只有传送带走得通。痛点:知识在文件里,不在模型里
Document(正文 + 索引卡片);②统一搬运协议——每种来源写一个 Loader,只要实现"产出 Document 迭代器"这一个接口。于是下游(切分器 D10、向量库 D11、检索器 D12)都只面向 Document 编程,来源无限扩展、下游一行不改。这和 D06 的消息体系是同一个招式:格式统一了,生态才能拼积木。| 类 | 位置 | 角色 |
|---|---|---|
BaseMedia | documents/base.py:34 | 公共底座:id + metadata |
Document | documents/base.py:288 | 统一容器:page_content + metadata |
Blob | documents/base.py:59 | 原始二进制(还没解析成文本的"生书") |
BaseLoader | document_loaders/base.py:26 | 搬运工协议:load / lazy_load / load_and_split |
Document:一张图书馆卡片
主角出场:Document(libs/core/langchain_core/documents/base.py:288),简单到让人意外:
# libs/core/langchain_core/documents/base.py:288
class Document(BaseMedia):
"""Class for storing a piece of text and associated metadata.
!!! note
`Document` is for **retrieval workflows**, not chat I/O. For sending
text to an LLM in a conversation, use message types from
`langchain.messages`.
Example:
document = Document(
page_content="Hello, world!", metadata={"source": "https://example.com"}
)
"""
page_content: str # ★正文:就是一段字符串
type: Literal["Document"] = "Document"
def __init__(self, page_content: str, **kwargs: Any) -> None:
"""Pass page_content in as positional or named arg."""
super().__init__(page_content=page_content, **kwargs) # 支持位置参数写法
page_content: str正文只是一个字符串——刻意简单。图片/音频等二进制不走这里(那是 Blob 的事),RAG 主线处理的就是文本。继承 BaseMedia(documents/base.py:34)从底座继承两个字段:id(可选唯一标识,向量库增量更新靠它)和 metadata: dict(索引卡片本体)。docstring 的提醒★源码特意警告:Document 是给检索流程用的,别拿它当聊天输入——对话该用 D06 的消息体系。两套容器各管各的场景。__init__ 重写只为让 Document("你好") 这种位置参数写法成立(Pydantic 默认必须关键字传参)。对高频类型,一点点书写体验也值得花心思。metadata 与 __str__ 的小心思
metadata 卡片上通常写什么?没有强制 schema,约定俗成的常客:
| 键 | 示例值 | 谁用它 |
|---|---|---|
source | "差旅制度.pdf" / URL | 回答时标注引用来源 |
page | 3 | PDF 加载器写入,定位原文 |
start_index | 1024 | 切分器(D10)写入:块在原文的起点 |
| 业务自定义 | {"dept": "财务部", "year": 2025} | 检索时按条件过滤(只搜财务部的文档) |
再看一个隐藏细节——Document.__str__(libs/core/langchain_core/documents/base.py:331)被特意重写了:
# libs/core/langchain_core/documents/base.py:331
def __str__(self) -> str:
"""Override `__str__` to restrict it to page_content and metadata."""
# The purpose of this change is to make sure that user code that feeds
# Document objects directly into prompts remains unchanged due to the
# addition of the id field (or any other fields in the future).
if self.metadata:
return f"page_content='{self.page_content}' metadata={self.metadata}"
return f"page_content='{self.page_content}'"
为什么重写 __str__很多人偷懒把 Document 直接塞进提示词(f-string 一格式化就调 __str__)。注释说得明白:如果用 Pydantic 默认的 __str__,将来给类加个新字段(比如 id),所有人的提示词内容都会悄悄变化,模型效果莫名抖动。锁死 __str__ 输出 = 保护下游提示词的稳定性。教训框架作者连"用户会怎么偷懒"都考虑到了。你自己设计数据类型时也记住:会被渲染进提示词的对象,它的字符串形式就是公共 API,别随意变。BaseLoader:搬运工协议
搬运工协议 BaseLoader(libs/core/langchain_core/document_loaders/base.py:26):
# libs/core/langchain_core/document_loaders/base.py:26
class BaseLoader(ABC):
"""Interface for document loader.
Implementations should implement the lazy-loading method using generators
to avoid loading all documents into memory at once.
`load` is provided just for user convenience and should not be overridden."""
# Sub-classes should not implement this method directly. Instead, they
# should implement the lazy load method.
def load(self) -> list[Document]: # base.py:37
"""Load data into `Document` objects."""
return list(self.lazy_load()) # ★一行:把传送带上的书全收进列表
async def aload(self) -> list[Document]:
return [document async for document in self.alazy_load()]
ABC 但 load 有默认实现协议的分工很清楚:子类只写 lazy_load(怎么一本本产出),load 白送(= list(lazy_load()))。写一个自定义 Loader 只需实现一个生成器函数。"should not be overridden"docstring 两次强调别重写 load。为什么?如果你只实现了 load,别人调你的 lazy_load 时(见 L05)只能退化成"先全加载再假装流式",懒加载的好处全没了。aload异步版一样是"收集 alazy_load"。LangChain 全家的接口都是 同步/异步、全量/流式 四件套成对出现——D05 的 ChatModel 也是这个套路。lazy_load:传送带 vs 一车全卸
lazy_load 的默认实现(libs/core/langchain_core/document_loaders/base.py:91)藏着一个聪明的互相兜底:
# libs/core/langchain_core/document_loaders/base.py:91
def lazy_load(self) -> Iterator[Document]:
"""A lazy loader for `Document`."""
if type(self).load != BaseLoader.load: # ★子类重写了 load(老式写法)?
return iter(self.load()) # → 用它的 load 凑一个迭代器
msg = f"{self.__class__.__name__} does not implement lazy_load()"
raise NotImplementedError(msg) # 两个都没实现 → 明确报错
type(self).load != BaseLoader.load★反射检查:"你重写过 load 吗?"重写过(很多老 Loader 只实现了 load),就退化成 iter(self.load())——先全加载再逐个吐,接口兼容但失去省内存的实惠。双向兜底合起来看:实现了 lazy_load → load 白送(L04);只实现了 load → lazy_load 也能凑合用(这里)。新老两种写法都能同时提供两个接口,生态平滑演进。为什么执念于懒加载想象加载 10GB 的日志目录:load 要把全部 Document 同时放进内存;lazy_load 一次只在内存里放一本,边加载边切分边入库(下游 D10/D11 都吃迭代器),内存恒定。load_and_split 与 Blob:两个配角
配角一:load_and_split(libs/core/langchain_core/document_loaders/base.py:53)——加载完顺手切块,预告了明天的主角:
# libs/core/langchain_core/document_loaders/base.py:53
def load_and_split(self, text_splitter: TextSplitter | None = None) -> list[Document]:
"""Load `Document` and split into chunks. Chunks are returned as `Document`.
!!! danger
Do not override this method. It should be considered to be deprecated!"""
if text_splitter is None:
...
text_splitter_: TextSplitter = RecursiveCharacterTextSplitter() # ★默认切分器
else:
text_splitter_ = text_splitter
docs = self.load()
return text_splitter_.split_documents(docs) # 切完还是 list[Document]
默认 RecursiveCharacterTextSplitter不传切分器就用"递归字符切分"——正是 Day10 的主角。注意它来自独立包 langchain-text-splitters,没装会报 ImportError 并提示安装。切完还是 Documentsplit_documents 输入输出都是 Document 列表——"通货"形态不变,只是面额变小、卡片继承(D10 细看)。danger: 别重写,视同弃用官方更推荐显式两步:loader.lazy_load() 再自己调切分器——加载和切分解耦,还能保持流式。这个便捷方法用了 self.load(),大数据集会吃满内存。配角二:Blob(libs/core/langchain_core/documents/base.py:59)——"还没拆封的生书":原始二进制 + MIME 类型 + 路径,支持 from_path(base.py:214)延迟读盘、as_string(base.py:158)按编码解码。它把"从哪拿字节"和"怎么解析成 Document"分开,PDF 解析器这类"拆封工"(BlobParser)就能独立复用。
串起来 + 今日小结
class TxtDirLoader(BaseLoader): def __init__(self, dir): self.dir = dir def lazy_load(self): for p in Path(self.dir).glob("*.txt"): yield Document(page_content=p.read_text(), metadata={"source": str(p)})用起来:
docs = TxtDirLoader("./notes").load() → 比如得到 3 条,第一条 Document(page_content="差旅报销上限为每晚 500 元…", metadata={"source": "notes/差旅制度.txt"})。load 是白送的(base.py:37),大目录时改用 for doc in loader.lazy_load(): 逐本处理,内存恒定。👶 小白:Document 和 D06 的 HumanMessage 都是"文本 + 附加信息",为什么要两套容器?
👨🏫 老师:因为它们在两条不同的流水线上流动。消息流向模型 API(有角色、要序列化成厂商聊天格式、带 tool_calls);Document 流向检索系统(要切分、向量化、按 metadata 过滤)。源码在 Document 的 docstring 里专门写了这条 note(documents/base.py:291):检索用 Document,聊天用 messages。两者的交汇点在 D12:检索出来的 Document 会被渲染成文本塞进提示词,变成一条 HumanMessage/SystemMessage 的一部分——那一刻通货完成"换轨"。
🧠 今天你应该能回答
- Document 的两个主角字段?(page_content 正文 + metadata 索引卡片,documents/base.py:288/306)
- metadata 通常写什么?(source/page/start_index/业务过滤字段)
- 为什么重写 __str__?(Document 常被直接塞提示词,锁死字符串形式保护下游稳定,base.py:331)
- 写自定义 Loader 要实现哪个方法?(lazy_load 生成器;load 白送,document_loaders/base.py:26/37)
- lazy_load 的默认实现怎么兜底?(反射检查子类是否重写了 load,退化成 iter(load()),base.py:91)
- load_and_split 默认用什么切分?(RecursiveCharacterTextSplitter,且官方不建议依赖此便捷方法,base.py:53)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/langchain/libs/core/langchain_core
# 1. 统一容器
sed -n '288,320p' documents/base.py # Document:page_content + metadata
sed -n '331,347p' documents/base.py # __str__ 为什么锁死
sed -n '34,58p' documents/base.py # BaseMedia:id + metadata 底座
# 2. 搬运工协议
sed -n '26,52p' document_loaders/base.py # BaseLoader + load = list(lazy_load())
sed -n '91,100p' document_loaders/base.py # lazy_load 的反射兜底
sed -n '53,88p' document_loaders/base.py # load_and_split(danger 注释)
# 3. 生书 Blob
grep -n "class Blob\|def from_path\|def as_string" documents/base.py
libs/text-splitters 看切分的艺术:TextSplitter 的 chunk_size/chunk_overlap 怎么定义、_merge_splits 滑动窗口怎么工作、RecursiveCharacterTextSplitter 为什么按"段→行→词→字"递归降级。