最近在做一个知识库预处理的小项目,需要把各种乱七八糟格式的文档塞给后续的向量化流程。一开始图省事,直接用LangChain的TextLoader、CSVLoader,结果越用越别扭:想对解析逻辑做点定制,发现要么得继承加载器重写,要么得迁就加载器里写死的参数。后来翻源码才发现,LangChain里真正干活的根本不是这些看起来很方便的加载器,而是藏在底层的Blob和BaseBlobParser。把这两样东西拿出来自己组装配线之后,整个文档加载流程瞬间清爽了很多。
这篇文章就聊聊:Blob和BlobParser到底是什么,为什么它们比现成的文档加载器更适合做自定义加载,以及我如何在真实项目中用它们替换掉原来的加载器。
1. 为什么我决定绕开现成文档加载器
1.1 看起来方便的加载器,其实藏着三层包装
很多人用LangChain的时候,都有一种错觉:TextLoader就是读文本文件,CSVLoader就是读csv,PDFLoader就是读PDF。但只要你点进去看源码,就会发现这些加载器几乎没有做任何解析逻辑,它们只是把“找文件”和“解析字节”这两件事交给了底层的两个组件:
Blob:负责描述一批二进制数据,也就是文件的完整内容或者内存里的一段字节。BaseBlobParser:负责把Blob转成LangChain的统一数据结构Document。
TextLoader之类的高级加载器,内部多是对FileSystemBlobLoader和TextBlobParser的简单排列组合。FileSystemBlobLoader根据路径生成Blob列表,TextBlobParser逐个解析并产出文档。换句话说,高级加载器本质上就是个“传话人”,真正的逻辑早被封装到底层了。
这种封装对常见场景很友好,但也会带来副作用:你想让某个加载器拥有新的行为,往往只能继承它重写整个load方法,或者期待官方提供新参数。比如我想在加载Markdown时顺带保留标题层级,TextLoader做不到;想在读CSV时把文件名注入到每一行文档的metadata里,CSVLoader也做不到。这时候你就会意识到,真正可控的是底层那套Blob + Parser链条,而不是外层加载器。
1.2 常规加载器解决不了的问题
在我这个模拟项目X里,遇到的典型问题有三个:
- 来源不统一:一部分文件在磁盘上,一部分是接口返回的JSON文本,一部分临时存在于内存里。传统加载器要么只接受文件路径,要么强制你先落盘再读取。
- 解析规则个性化:对Markdown文件,我想忽略代码块里的某些占位符;对CSV文件,我想按指定的分隔符解析,并且给每条记录加上特定业务标签。这些规则没法塞进现成加载器里。
- 惰性加载和大文件处理:项目里有些日志文件可能有几十MB,用现成加载器的
load()方法一次全读进内存,很容易被打爆。
这些问题归根结底指向同一个答案:不把加载器当成黑盒,而是直接使用构成它的那两块积木——Blob和BlobParser。这也是这篇文章标题里“代替”的真正含义:不是不用LangChain,而是不再依赖那层封装好的加载器。
2. Blob和BlobParser到底是个什么东西
2.1 Blob:一个随时能进内存的“数据盒子”
Blob这个名字来自数据库领域的“二进制大对象”,在LangChain里是一个很朴素的类。它用统一的属性描述一段数据:
data:原始字节,bytes类型。source:数据的来源,通常是文件路径或字符串来源标识。mime_type:MIME类型,比如text/markdown、text/csv。encoding:文本编码,比如utf-8、gbk。
有了这四个字段,你就不需要关心数据到底来自哪个文件,还是来自内存。Blob提供了两个最常用的静态构造方法:
from langchain_core.document_loaders import Blob # 从磁盘路径构造 blob_from_path = Blob.from_path( "docs/readme.md", mime_type="text/markdown", encoding="utf-8" ) # 从内存字节构造 blob_from_data = Blob.from_data( b"title,value\nfoo,1", source="api_response.csv", mime_type="text/csv" )你可以把Blob想象成一个标准快递箱:路径是寄件人地址,字节是包裹里的真实物品,MIME类型是包裹上的物品说明,编码是物品的包装方式。后面所有解析器都只需要接收这个快递箱,不需要知道物品最初是从仓库还是从流水线上直接装进来的。
这带来的直接好处是:加载数据源的逻辑和解析逻辑彻底解耦。你可以在程序里统一生成Blob列表,然后再统一解析。
2.2 BlobParser:把“数据盒子”变成Document的转换器
LangChain官方给解析器定义的接口名是BaseBlobParser,社区里经常直接叫BlobParser。它的核心只有一个方法:
def lazy_parse(self, blob: Blob) -> Iterator[Document]: ...lazy_parse接收一个Blob,返回一个Document的迭代器。为什么是迭代器而不是单个Document?因为一个Blob可能拆分成多条文档。比如一份CSV文件可以按行拆成N条Document;一个带目录的Markdown可以按章节拆成多个块。如果你只需要返回一条文档,yield一次就行。
一个最简单的文本解析器长这样:
from typing import Iterator from langchain_core.documents import Document from langchain_core.document_loaders import BaseBlobParser, Blob class SimpleTextParser(BaseBlobParser): def lazy_parse(self, blob: Blob) -> Iterator[Document]: text = blob.as_string() yield Document( page_content=text, metadata={"source": blob.source} )blob.as_string()是Blob提供的便捷方法,它会根据构造时传入的encoding或mime_type把字节解码成字符串。对简单的场合,自己实现一个解析器就是重写这个lazy_parse方法。
有了这个解析器,你就可以不看Loader代码,直接对任意Blob做文档转换:
blob = Blob.from_path("docs/readme.md", mime_type="text/markdown") parser = SimpleTextParser() doc = next(parser.lazy_parse(blob)) print(doc.page_content[:100]) print(doc.metadata)2.3 加载器、Blob、Parser三者协作的运行关系
很多人不理解为什么要拆成三个角色,直接一个load()方法全搞定不香吗?拆开来是为了应对组合变化。
LangChain标准加载器的执行链条可以描述为:
文件路径集合 -> FileSystemBlobLoader -> List[Blob] -> BaseBlobParser -> Iterator[Document]FileSystemBlobLoader:负责按路径匹配规则(比如glob模式)生成一批Blob。BaseBlobParser:负责从字节到Document。BaseLoader:只负责把上面两步拼起来,对外统一暴露load()和lazy_load()。
所以当你觉得加载器不够用时,其实你并不是要“干掉加载器”,而是要拆掉这个链条再自己重拼。拼法很灵活:
- 数据源是本地文件:用
FileSystemBlobLoader或手动Blob.from_path。 - 数据源是内存:手动
Blob.from_data。 - 数据源是网络API返回:先拿到字节,再
Blob.from_data。 - 解析规则自己定:继承
BaseBlobParser重写lazy_parse。
只要Blob统一了数据入口,Parser统一了出口,中间怎么走完全取决于你的需求。
3. 实战:用Blob和BlobParser重构文档加载流程
3.1 项目场景与改造目标
模拟项目X的文档加载需求是这样的:
- 需要加载一组Markdown文件,并在解析时忽略含占位符
{{todo}}的段落。 - 需要加载一个CSV文件,每条记录变成一条
Document,且metadata里要加入文件名和业务标签。 - 需要把一段接口返回的动态JSON文本也变成
Document,不经过磁盘。
标准加载器并不适合这种混合场景。我的改造目标是写一套自己的轻量加载函数,用Blob封装来源,用BlobParser定义解析规则,最后统一输出Document列表。
3.2 写一个支持多种格式的BlobParser
核心解析器我设计为一个支持多格式的类。它通过blob.mime_type分派到不同的内部解析函数,这样比写多个类更直观:
import csv import io import json from typing import Iterator from langchain_core.documents import Document from langchain_core.document_loaders import BaseBlobParser, Blob class MultiFormatParser(BaseBlobParser): def __init__(self, business_tag: str): self.business_tag = business_tag def lazy_parse(self, blob: Blob) -> Iterator[Document]: mime = blob.mime_type or "text/plain" if mime == "text/markdown": yield from self._parse_markdown(blob) elif mime == "text/csv": yield from self._parse_csv(blob) elif mime == "application/json": yield from self._parse_json(blob) else: yield from self._parse_plain_text(blob) def _parse_markdown(self, blob: Blob) -> Iterator[Document]: text = blob.as_string() # 简单过滤掉包含占位符的行,完整业务场景可能需要按标题分块 filtered_lines = [ line for line in text.splitlines() if "{{todo}}" not in line ] yield Document( page_content="\n".join(filtered_lines), metadata={"source": blob.source, "format": "markdown", "business_tag": self.business_tag}, ) def _parse_csv(self, blob: Blob) -> Iterator[Document]: text = blob.as_string() reader = csv.DictReader(io.StringIO(text)) for row in reader: page_content = json.dumps(row, ensure_ascii=False) yield Document( page_content=page_content, metadata={"source": blob.source, "format": "csv", "business_tag": self.business_tag}, ) def _parse_json(self, blob: Blob) -> Iterator[Document]: text = blob.as_string() data = json.loads(text) # 假设接口返回的是一个对象列表 if isinstance(data, list): for item in data: yield Document( page_content=json.dumps(item, ensure_ascii=False), metadata={"source": blob.source, "format": "json", "business_tag": self.business_tag}, ) else: yield Document( page_content=text, metadata={"source": blob.source, "format": "json", "business_tag": self.business_tag}, ) def _parse_plain_text(self, blob: Blob) -> Iterator[Document]: text = blob.as_string() yield Document( page_content=text, metadata={"source": blob.source, "format": "text", "business_tag": self.business_tag}, )这个解析器的价值在于,以后项目里每增加一种格式,只需要在lazy_parse里加一个分支,而不需要动业务调用代码。
3.3 用BlobLoader批量喂数据
光有解析器还不够,还要解决第一批Blob从哪里来的问题。如果文件都在磁盘上,可以用FileSystemBlobLoader,它对路径支持通配符:
from langchain_core.document_loaders import FileSystemBlobLoader blob_loader = FileSystemBlobLoader( path="docs/", glob="**/*.md", ) blobs = list(blob_loader.yield_blobs())如果你对glob不熟悉,可以把它理解成路径匹配规则:**/*.md的意思是递归匹配所有以.md结尾的文件。FileSystemBlobLoader会自动把匹配到的文件生成Blob,并且Blob.source就是文件的绝对路径。
但内存里的JSON数据没法用文件系统加载器,这时候就手动构造:
api_json_bytes = '{"name":"demo","value":42}'.encode("utf-8") extra_blob = Blob.from_data( api_json_bytes, source="api_response_demo.json", mime_type="application/json", )Blob.from_data的source参数可以填一个客户端自定义的来源标识,后续会出现在每条Document的metadata里。
3.4 组装成一个轻量加载函数
有了Blob来源和解析器,最后组装一个统一入口:
from typing import Iterable from langchain_core.document_loaders import Blob from langchain_core.documents import Document def load_documents( blobs: Iterable[Blob], parser: MultiFormatParser, ) -> list[Document]: docs: list[Document] = [] for blob in blobs: for doc in parser.lazy_parse(blob): docs.append(doc) return docs实际运行时,只需要这样调用:
file_blobs = list(blob_loader.yield_blobs()) all_blobs = [*file_blobs, extra_blob] parser = MultiFormatParser(business_tag="sim-project-x") documents = load_documents(all_blobs, parser) print(len(documents)) for doc in documents[:3]: print(doc.page_content[:80]) print(doc.metadata)这套流程已经完全替代了TextLoader和CSVLoader。你也许注意到了,我甚至连BaseLoader都没有继承。是的,不强求。如果你不需要对外提供load()和lazy_load()两个方法,直接写一个普通函数反而更清晰。
4. 替换LangChain加载器后的效果与选型建议
4.1 核心收益:可控的解析与元数据
在用这套底层方案后,最大的感受是控制感回来了。以前用加载器,文档的page_content和metadata都是别人定的;现在一切都由自己的lazy_parse决定。
拿CSV举例。CSVLoader默认会把整个文件读成一个大字符串,然后按行拆分成多条Document。听起来合理,但它不会把每一行数据里的业务字段拆出来。我的需求是要让每条Document的正文是行数据的完整JSON串,同时metadata里放入业务标签。这需求放在CSVLoader上基本只能靠初始化参数硬凑,但放在BlobParser里,就是几行代码的事。
另一个收益是元数据控制。Blob自带的source字段会被我直接透传到Document.metadata里。后续做基于来源的数据回溯时非常方便。如果未来要对文档做权限过滤,也可以直接在解析阶段给metadata打上额外标记。
4.2 对比:什么时候用Blob+Parser,什么时候继续用现成加载器
我整理了一份选型对照,写代码前可以先对照一下:
| 维度 | 现成文档加载器 | Blob + BlobParser |
|---|---|---|
| 接入新格式 | 等官方或社区实现 | 自己写解析分支即可 |
| 自定义metadata | 受限于加载器参数 | 完全可控 |
| 内存数据加载 | 通常需要临时文件 | 原生支持Blob.from_data |
| 惰性读取 | 部分支持lazy_load | lazy_parse天然返回迭代器 |
| 代码量 | 少、上手快 | 略多,但更灵活 |
| 对LangChain依赖 | 依赖高层模块 | 只需依赖langchain_core的少量类 |
我的建议是:如果你的文档类型很标准,比如就是几个纯文本文件,用现成加载器没问题。文档格式越杂、来源越不统一、metadata要求越个性化,就越应该直接走Blob + Parser路线。等到你发现加载器参数无法满足需求时,再换底层方案也不迟。
4.3 性能与小细节:惰性解析和编码处理
很多人忽略lazy_parse返回迭代器的意义。标准load()方法会把所有文档一次性存入列表,如果你只对一个超大文件中的前几段感兴趣,这个行为会造成大量浪费。而lazy_parse配合迭代器,可以逐个生成Document。在组装流程时,可以这样优雅地处理:
def lazy_load_documents(blobs, parser): for blob in blobs: yield from parser.lazy_parse(blob)这样外层调用方用for doc in lazy_load_documents(...)遍历时,内存里始终只保留少数Document。对大日志、大CSV非常友好。
编码处理上,普通Blob.parse时容易遇到的坑是猜测编码错误。官方TextBlobParser会用检测库来猜,猜错就会出现乱码。实践中的稳妥做法是在Blob.from_path时就传入明确的encoding,比如:
Blob.from_path("docs/report.csv", mime_type="text/csv", encoding="utf-8")如果无法预先知道编码,再使用blob.as_string()默认行为。但在我做的项目里,统一约定所有入库文件为UTF-8,这个问题就不存在了。
5. 常见问题与排查技巧
5.1 blob.as_string()抛编码错误
这是从Blob取文本时最常见的报错。原因通常是构造Blob时没有指定encoding,而as_string()内部检测到的编码不对。排查顺序如下:
- 确认原始数据到底是什么编码,Windows下常见的
gbk或gb2312经常中招。 - 在
Blob.from_path或Blob.from_data时显式传入encoding参数。 - 如果仍然报错,先拿最小片段测试:
raw = blob.data print(raw[:50]) # 看一眼字节,确认是否带BOM或特殊头如果数据是UTF-8带BOM,utf-8不兼容时可以考虑先用codecs.BOM_UTF8判断再解码。
5.2 加载多个文件时source路径很乱
用FileSystemBlobLoader时,Blob.source默认是匹配到的完整路径。不同系统下可能带/或\,后续如果要拿文件名做过滤会很难受。
我的习惯是解析时统一格式化:
from pathlib import Path def normalize_source(source: str) -> str: return str(Path(source).resolve())然后在Blob.from_path之前先处理好路径,或者在解析器里对blob.source做归一化。注意不要在解析器里反复调用resolve(),开销不高但没必要。
5.3 内存数据如何保留原始文件名
用Blob.from_data时,经常有人忘记填source参数,或者只填一个"memory"字符串。结果后续所有Document.metadata["source"]都变成同一个值,无法区分。
正确做法是给内存数据一个有意义的来源标识。比如接口返回数据时,可以拼接接口路径和请求时间:
source_name = f"api:/data/records/{datetime.now().isoformat()}" Blob.from_data(data_bytes, source=source_name, mime_type="application/json")源信息不能省,否则后续做问题定位时,连数据从哪来的都不知道。
5.4 真的需要继承BaseLoader吗
不一定。如果你只是项目内部使用,写一个普通生成器函数完全够用。只有当你想继续复用LangChain生态里的其他加载器,或者希望自己的加载器能被当作BaseLoader传给外部工具时,才需要实现load()和lazy_load()。
我这次重构就没有继承BaseLoader。原因很简单:项目里没有任何一个第三方模块要求我必须传一个BaseLoader进去。如果以后有需要,再包一层适配也不晚。
6. 从这次重构里总结的几点体会
6.1 每次想加新格式时,只需要加新的解析分支
项目里后来又加了一种日志格式,日志文件前缀是task_,每行一个JSON。我没有改调用方代码,只是在MultiFormatParser里增加一个text/*分支,并把日志解析成多条Document。这比维护多个独立加载器要轻松得多。
6.2 别把“代替”理解成“不用LangChain”
替换的是加载器这一层抽象,不是整个框架。我仍然用Document结构,仍然用Blob,甚至仍然间接使用langchain_core的模块。这种替代更像是在LangChain内部选了一条更底层的路线,而不是跟LangChain划清界限。
6.3 适合这样做的场景清单
根据这次经验,适合直接上Blob + BlobParser的场景有这些:
- 文档类型多且解析规则经常变。
- 数据源同时包含磁盘、内存、接口返回。
- 需要在
Document的metadata里写入业务字段。 - 需要处理超大文件,希望惰性读取。
- 已经对LangChain的加载器源码熟悉到不需要再看文档。
如果你只是临时搭一个Demo,用现成加载器完全没问题。但如果你想长期维护一套文档处理管线,底层这套Blob + Parser思路能让你少踩很多坑。
最后再分享一个小技巧:在解析器内部,可以先用blob.mime_type判断格式,但不要完全信任它。有些文件后缀和MIME类型对不上,比如一个.txt文件实际是CSV内容。实战中我会在Blob.from_path后额外抽样检查前几百个字节,再决定走哪个解析分支。这是从一次把CSV当纯文本解析导致错乱后总结出来的教训。总之,自己控制数据源和解析器的组装时,多留一个心眼永远比依赖默认行为可靠。