Day 09 / 共 20 天 · 阶段3 数据与 RAG

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"。

📍 你在 20 天里的位置(阶段3:数据与 RAG · D09-12)
S2 模型/消息/提示 D09 Document/加载 D10 文本切分 D11 Embedding/向量库 D12 Retriever/RAG链 S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 今天的世界观是图书馆。类比一:Document 是一张带卡片的书页——正面是内容本身(page_content),背面钉着一张索引卡片metadata:来自哪本书、第几页、哪年出版)。检索系统翻的是卡片,读者读的是正文。类比二:BaseLoader 是图书馆的进书通道load() 像"一卡车书一次全卸进大厅"(内存全占),lazy_load() 像"传送带一本一本送进来、上架一本再来一本"(恒定内存)——馆藏百万册时,只有传送带走得通。
L01

痛点:知识在文件里,不在模型里

🤔 痛点你问模型"我们公司报销上限是多少",它只能瞎编——这条知识在你内网的《差旅制度.pdf》里。RAG 的思路是"先查资料再回答",但资料五花八门:PDF、Word、网页、Notion、数据库……每种来源的读取方式都不同。如果检索、切分、向量化的代码要针对每种格式各写一套,整个 RAG 生态就没法搭。
💡 本质:先统一容器,再统一搬运LangChain 拆成两步:①统一容器——不管来源是什么,进了系统一律装进 Document(正文 + 索引卡片);②统一搬运协议——每种来源写一个 Loader,只要实现"产出 Document 迭代器"这一个接口。于是下游(切分器 D10、向量库 D11、检索器 D12)都只面向 Document 编程,来源无限扩展、下游一行不改。这和 D06 的消息体系是同一个招式:格式统一了,生态才能拼积木。
位置角色
BaseMediadocuments/base.py:34公共底座:id + metadata
Documentdocuments/base.py:288统一容器:page_content + metadata
Blobdocuments/base.py:59原始二进制(还没解析成文本的"生书")
BaseLoaderdocument_loaders/base.py:26搬运工协议:load / lazy_load / load_and_split
L02

Document:一张图书馆卡片

主角出场:Documentlibs/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 主线处理的就是文本
继承 BaseMediadocuments/base.py:34)从底座继承两个字段:id(可选唯一标识,向量库增量更新靠它)和 metadata: dict(索引卡片本体)。
docstring 的提醒★源码特意警告:Document 是给检索流程用的,别拿它当聊天输入——对话该用 D06 的消息体系。两套容器各管各的场景。
__init__ 重写只为让 Document("你好") 这种位置参数写法成立(Pydantic 默认必须关键字传参)。对高频类型,一点点书写体验也值得花心思。
大白话整个 RAG 世界的"通货"就这么点东西:一段文字 + 一张说明它来历的卡片。切分(D10)是把大额通货换零钱,向量化(D11)是给通货编索引,检索(D12)是按索引找通货——通货本身始终是 Document。
L03

metadata 与 __str__ 的小心思

metadata 卡片上通常写什么?没有强制 schema,约定俗成的常客:

示例值谁用它
source"差旅制度.pdf" / URL回答时标注引用来源
page3PDF 加载器写入,定位原文
start_index1024切分器(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,别随意变。
L04

BaseLoader:搬运工协议

搬运工协议 BaseLoaderlibs/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 也是这个套路。
L05

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(一车全卸) vs lazy_load(传送带) 数据源 PDF / 网页 / DB load() list[Document] 10 万条同时在内存 馆藏太大 → OOM lazy_load() Iterator[Document] 一次一本 yield → 处理 → 再 yield 下一本 下游流水线 切分(D10)→入库(D11) 协议的互相兜底(document_loaders/base.py) 子类实现 lazy_load → load = list(lazy_load()) 白送(:37);子类只写了 load → lazy_load = iter(load()) 凑合(:91)
图注:新写 Loader 永远实现 lazy_load(生成器 yield),既省内存又白得一个 load。
L06

load_and_split 与 Blob:两个配角

配角一:load_and_splitlibs/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(),大数据集会吃满内存。

配角二:Bloblibs/core/langchain_core/documents/base.py:59)——"还没拆封的生书":原始二进制 + MIME 类型 + 路径,支持 from_pathbase.py:214)延迟读盘、as_stringbase.py:158)按编码解码。它把"从哪拿字节"和"怎么解析成 Document"分开,PDF 解析器这类"拆封工"(BlobParser)就能独立复用。

💡 本质:进书三段式完整的进书流水线其实是:Blob(生书,字节)→ Parser(拆封解析)→ Document(上架成品)。简单场景里 Loader 一个人把三段全干了;复杂场景(比如同一批 PDF 想换不同解析器)就按三段拆开各自复用——图书馆收书也是"卸货、编目、上架"三道工序分工。
L07

串起来 + 今日小结

📝 真实值:写一个自定义 Loader 只要几行 按协议只需实现 lazy_load 生成器:
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
明日预告 · Day 10:书搬进来了,但一整本《差旅制度》几万字,检索时不可能整本塞给模型。明天去独立包 libs/text-splitters 看切分的艺术:TextSplitter 的 chunk_size/chunk_overlap 怎么定义、_merge_splits 滑动窗口怎么工作、RecursiveCharacterTextSplitter 为什么按"段→行→词→字"递归降级。
← Day 08 输出解析与结构化输出 Day 10 · 文本切分 →