文档加载器
📂 1. LangChain 提供了哪些常见文档加载器?至少说出 5 种并说明适用文件类型。¶
LangChain 的文档加载器生态极其丰富,几乎覆盖了你能接触到的所有非结构化或半结构化数据源。我这里分享 5 个我在项目里经常接触的,外加它们的适用文件类型和一些实践心得:
| 加载器 | 适用文件类型 | 核心依赖 & 备注 |
|---|---|---|
| PyPDFLoader | .pdf 文本型 PDF | 基于 pypdf,轻量,按页加载,适合纯文字 PDF,但对复杂排版(多栏、表格)几乎无力。 |
| UnstructuredPDFLoader | .pdf 通用 PDF | 底層是 unstructured 库,能对 PDF 进行版面分割,提取标题、正文、表格等元素。文档质量高时首选这个。但安装较重,依赖 libmagic、poppler 等。 |
| CSVLoader | .csv 逗号分隔值 | 可指定列作为 page_content 和 metadata 的来源,简单好用。处理大文件时注意内存。 |
| UnstructuredMarkdownLoader | .md Markdown 文件 | 保留标题层级作为 metadata,可控制分割方式(按标题、按块等)。 |
| TextLoader | .txt 纯文本 | 最简单的加载器,几乎无额外依赖。编码需要关注,默认为 UTF-8,遇到非 UTF-8 文件可能报错。 |
| WebBaseLoader | HTML 网页 | 基于 requests + beautifulsoup4,可以抓取静态网页内容。对于需要登录或动态渲染的页面就无能为力。 |
| Docx2txtLoader | .docx Word 文档 | 处理现代 Word 文件,保留文本和部分样式。 |
| JSONLoader | .json JSON 数据 | 用 jq 语法(可选)提取指定字段作为内容,灵活处理嵌套结构。 |
除了这些,还有 S3FileLoader、GCSFileLoader、NotionDBLoader 等平台专属加载器。实战中我总结了两条原则:
-
能
Unstructured就别用基础加载器。Unstructured系列对版面分析的支持远超PyPDF等老派工具,尤其在处理复杂的 PDF、PPT、图片(配合 OCR)时优势明显。 -
永远在加载后检查 metadata。加载器会自动填充
source、page等元数据,这些字段后面检索时会非常有用,别让它们悄悄溜走。
🧩 2. 如何处理一个同时包含表格和文字段落的 PDF?用哪个加载器比较好?¶
这是一个经典难题。普通的 PyPDFLoader 提取文本时会把表格拆成残破的段落,丢失结构。专门提取表格可以用 camelot 或 tabula,但会把文字段落丢掉。LangChain 并没有一个开箱即用的“表格+文字”一体化加载器,所以我们要自己做组合。
推荐方案:UnstructuredPDFLoader + 自定义后处理
Unstructured 库(partition_pdf)可以识别出文档中的 Table 元素,并将其内容提取为文本或 HTML 表格。你可以利用这个特性:
from langchain_community.document_loaders import UnstructuredPDFLoader
loader = UnstructuredPDFLoader(
"report.pdf",
mode="elements", # 返回元素粒度的文档
strategy="hi_res", # 高分辨率策略,准确度高但慢
)
docs = loader.load()
# 此时每个 docs[i] 是一个元素,其 metadata 包含 "category" 字段:
# 可能的类别:Title, NarrativeText, Table, ListItem, etc.
之后,你可以遍历 docs,对于 category == "Table" 的元素,可以保留其 page_content(已经是表格文本表示)或者进一步用 camelot 在该页上精确提取。对于 NarrativeText 等元素,直接使用其文本。
如果表格特别复杂,我更倾向于双引擎处理:
-
用
PyPDFLoader先提取纯文本,用作全文搜索的语料。 -
再用
camelot-py或tabula-py提取所有页面上的表格,将表格转换为 Markdown 或 JSON 字符串,单独存入向量库,并附加page_number等 metadata。查询时,从两路召回后合并排序。
这种方法虽然麻烦,但能保证表格信息和文字信息都不丢失,特别适合财报、科研论文等场景。别指望一个加载器解决所有问题,针对不同模态数据用不同的工具,然后统一到 Document 对象,是最务实的工程策略。
📄 3. 写一段代码,使用 PyPDFLoader 加载 PDF 并提取每一页的内容。¶
from langchain_community.document_loaders import PyPDFLoader
# 初始化加载器,传入 PDF 文件路径
loader = PyPDFLoader("example.pdf")
# 方式一:load() 一次性加载所有页,返回 Document 列表
documents = loader.load()
print(f"总页数: {len(documents)}")
for i, doc in enumerate(documents):
print(f"--- 第 {i+1} 页 ---")
print(doc.page_content[:200]) # 预览前200字符
print(f"元数据: {doc.metadata}")
# 方式二:lazy_load() 延迟加载,适合大文件
for doc in loader.lazy_load():
print(doc.page_content)
实战里的一些细节:
-
PyPDFLoader默认会把每页作为一个独立的Document,metadata里会有{'source': 'example.pdf', 'page': 0}(页码从 0 开始),后续可以方便地根据页码回溯原文。 -
如果 PDF 是加密的,
PyPDFLoader也支持传入密码:PyPDFLoader("encrypted.pdf", password="1234")。 -
性能方面,
PyPDFLoader很快,但文本提取质量一般。遇到双栏排版时,它可能把左栏和右栏的文字交错提取,读起来像乱码。对这种 PDF,应换成UnstructuredPDFLoader并设置strategy="hi_res"。
🖼️ 4. 如果 PDF 是扫描版图片,LangChain 的加载器能直接处理吗?你需要额外做什么?¶
直接回答:绝大多数纯文本提取加载器(如 PyPDFLoader)处理不了扫描版 PDF,因为它们只是从 PDF 的文本层提取字符,而扫描件只有一张大图片,没有文本层,结果常常是空字符串或乱码。
你需要引入 OCR(光学字符识别)。LangChain 里有几个路径:
- 使用
UnstructuredPDFLoader+strategy="ocr_only"unstructured库集成了对pytesseract和tesseract的调用。只要你安装了tesseract并配置好语言包(如chi_sim),就可以这样用:
from langchain_community.document_loaders import UnstructuredPDFLoader
loader = UnstructuredPDFLoader(
"scanned.pdf",
mode="elements",
strategy="ocr_only", # 完全只用 OCR
languages=["eng", "chi_sim"], # 英文和简体中文
)
docs = loader.load()
-
注意:
strategy="hi_res"也会在需要时自动触发 OCR,但"ocr_only"强制走全 OCR,确保扫描件不会漏掉。 -
先 OCR 后加载 你也可以完全脱离 LangChain,自己用
pytesseract或云服务(如 Azure Document Intelligence、AWS Textract)将 PDF 转成可搜索 PDF 或直接提取文本,再把文本喂给TextLoader。这种方式的优点是可控性强,可以针对特定版式调优 OCR 参数。 -
使用云文档解析服务
AzureAIDocumentIntelligenceLoader、AmazonTextractPDFLoader等加载器直接封装了云端 OCR 服务,只需传入凭证,它们会处理扫描件并返回结构化的文档,支持表格、键值对等。这是企业级项目最省心的方案,但需要额外成本。
我的实践经验:如果扫描质量较高(300dpi 以上),本地 tesseract 效果不错;但如果文档有水印、印章、模糊文字,本地 OCR 可能产生大量噪音。此时云端 OCR 模型的准确率优势就体现出来了。同时,OCR 后的文本通常需要做一定的后处理(去噪、纠正常见识别错误),以免影响检索和 LLM 理解。
🌐 5. 如何从网页(URL)加载文档?有哪些注意事项(如反爬、动态内容)?¶
LangChain 提供了 WebBaseLoader 进行简单的网页抓取:
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://example.com/article")
docs = loader.load()
# docs[0].page_content 包含网页的文本内容,metadata 里有 source, title, language 等
注意事项与踩坑记录:
- 反爬虫措施:
WebBaseLoader默认使用requests库,User-Agent 为Python-urllib,很容易被 Cloudflare 等拦截。可以传入自定义header来伪装:
-
更稳健的做法是配置
requests的 Session,处理 cookies、重试等。 -
动态内容(JavaScript 渲染):
WebBaseLoader无法执行 JavaScript,SPA(单页应用)或依赖 JS 加载内容的页面只能抓到空壳。针对这类网站,你可以用SeleniumURLLoader或PlaywrightURLLoader:
from langchain_community.document_loaders import PlaywrightURLLoader
loader = PlaywrightURLLoader(urls=["https://example.com"])
docs = loader.load() # 会启动 headless 浏览器渲染页面
-
内容提取质量:默认提取器会移除
<script>、<style>标签,但页眉、侧边栏、广告等噪声依然可能混入。可以通过bs_kwargs指定 BeautifulSoup 的解析参数,或者继承WebBaseLoader重写_scrape方法,用更精准的 CSS 选择器只提取正文区域。 -
礼貌与合规:注意
robots.txt的限制,不要对目标网站造成过大的请求压力。可以增加requests_per_second限速。另外,抓取的内容要遵守网站的服务条款和版权法规。
📝 6. 你如何处理 Markdown 文件,同时保留标题层级等元数据?¶
UnstructuredMarkdownLoader 就是为这个场景设计的。它不仅能解析 Markdown,还能识别标题、列表、代码块等元素,并把标题作为元数据保留下来。
from langchain_community.document_loaders import UnstructuredMarkdownLoader
loader = UnstructuredMarkdownLoader("doc.md", mode="elements")
docs = loader.load()
for doc in docs:
print(doc.page_content)
print("元数据:", doc.metadata)
# 输出中可能包含:
# 'category': 'Title', 'category_depth': 1 表示一级标题
在 mode="elements" 模式下,每个文档元素(如一段文字、一个标题)会成为一个独立的 Document 对象。其 metadata 中会有:
-
category: 元素类型,如Title,NarrativeText,ListItem,CodeBlock等。 -
category_depth: 标题层级(仅当 category 为 Title 时出现),一级标题深度为 1,二级为 2,以此类推。 -
source: 文件路径。 -
languages: 语言检测结果(可配置)。
如果只想要保留标题层级但对文档进行分割,可以在后续使用 MarkdownHeaderTextSplitter 这种分割器,它能够根据标题层级切割文档,并把标题路径(如 # 标题1 > ## 标题2)注入到每个子文档的 metadata 中,这样既保证了上下文,又方便检索。
✂️ 7. 使用 UnstructuredMarkdownLoader 时,如何控制分割策略?¶
UnstructuredMarkdownLoader 本身并不直接做语义分割,它只是按 Markdown 元素(段落、标题等)拆分。如果我们需要更大的“块”而不是每个元素独立,需要结合 LangChain 的文本分割器 来控制粒度。但 UnstructuredMarkdownLoader 提供了一些参数来调整元素的聚合行为:
mode参数:"single":整个文件作为一个 Document。"elements":每个元素(段落、标题等)独立为一个 Document。这是保留精细结构的最佳模式。-
"paged":对于 Markdown 无意义,忽略。 -
strategy参数: "fast":使用内置的python解析,快速但不识别复杂元素。-
"hi_res":调用更重的模型,可以更好地分类元素,但 Markdown 一般"fast"够用。 -
post_processors参数:可以传入元素后处理函数,比如合并相邻的列表项、删除空元素等。
真正的分割策略控制是在后处理。加载得到小元素后,我用 RecursiveCharacterTextSplitter 或专门为 Markdown 设计的 MarkdownTextSplitter 进行重组,确保每个 chunk 不超过 token 限制。例如:
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
# 直接对 Markdown 文本进行分割
documents = splitter.split_text(markdown_text)
这样生成的文档会自动把当前所属的所有标题层级作为 metadata(如 Header 1: 项目介绍, Header 2: 架构设计),这是一种非常有效的结构保持策略。你也可先用 UnstructuredMarkdownLoader 加载为元素,再根据需要合并、分割,完全自定义。
🧹 8. 在数据加载阶段,如何过滤掉页眉、页脚等噪声内容?¶
页眉页脚、版权声明、页码等是检索系统的毒药,它们会污染 embedding,导致相似性搜索匹配到无关片段。在加载阶段过滤通常有下面几种方法:
- 使用
Unstructured系列加载器的后处理功能Unstructured的partition_pdf在底层会尝试识别页眉页脚,并将其标记为Header或Footer元素。我们可以利用UnstructuredPDFLoader加载后,根据metadata["category"]过滤掉这些元素:
loader = UnstructuredPDFLoader("file.pdf", mode="elements", strategy="hi_res")
all_docs = loader.load()
filtered_docs = [d for d in all_docs if d.metadata.get("category") not in ("Header", "Footer")]
不过,unstructured 对页眉页脚的识别并非百分百准确,尤其是奇偶页不同的页眉。
- 正则表达式清洗
如果页眉页脚的格式固定(如始终包含日期、公司名称),可以在加载后对
page_content应用正则替换。例如:
import re
for doc in docs:
# 移除类似 "机密文件 - 第 1 页" 的行
doc.page_content = re.sub(r'机密文件 - 第 \d+ 页\n?', '', doc.page_content)
这种方法可控性高,但需要为每种文档模板写规则,维护成本不低。
- 只提取特定区域(布局解析)
如果 PDF 排版规整,可以用
pdfplumber直接提取特定区域内的文本,排除掉 top/bottom 区域。例如:
import pdfplumber
with pdfplumber.open("file.pdf") as pdf:
page = pdf.pages[0]
# 裁剪掉上下 20mm 的区域
crop = page.within_bbox((0, 20, page.width, page.height-20))
text = crop.extract_text()
-
再手动构建 Document 对象。这对于固定版式的报告非常有效。
-
使用更高级的商业加载器
AzureAIDocumentIntelligenceLoader或AmazonTextractPDFLoader在解析时会输出结构化的区块类型,其中就包含“页脚”等标记,能直接过滤。虽然需付费,但准确度和泛化能力极佳。
我的建议:初期项目可以先使用 Unstructured 的 category 过滤 + 简单正则清洗,快速上线。随着文档种类增多,逐步迁移到布局解析或云服务方案,并把这些清洗逻辑统一封装成一个 DocumentTransformer,方便复用。
📊 9. 你如何加载 CSV 或 Excel 文件,并将每一行转化为一个 Document 对象?¶
CSV 文件:使用 CSVLoader,可以配置将哪一列作为主要内容,其余列放入 metadata。
from langchain_community.document_loaders import CSVLoader
loader = CSVLoader(
file_path="data.csv",
source_column="context", # 指定作为 page_content 的列
metadata_columns=["title", "date"], # 这些列会成为 metadata
csv_args={"delimiter": ",", "quotechar": '"'},
encoding="utf-8",
)
docs = loader.load()
# 结果是一个 Document 列表,每行一个 Document
如果不想指定单一内容列,也可以不设 source_column,此时整个行会被转换为 page_content(通常为所有列的值拼接)。我个人更倾向于显式指定 source_column,让内容清晰。
Excel 文件:UnstructuredExcelLoader 支持 .xlsx 和 .xls。
from langchain_community.document_loaders import UnstructuredExcelLoader
loader = UnstructuredExcelLoader(
"data.xlsx",
mode="elements", # "single" 整个文件一个doc
)
docs = loader.load()
# 在 mode="elements" 下,每个 sheet 里的每一行可能作为一个元素,
# 但具体行为依赖 Unstructured 的解析逻辑。
注意,Excel 的结构更复杂,包含多个 sheet、合并单元格等。如果我们需要精确控制“每行一个 Document”,我常会用 pandas 先读取 Excel,再手动构建:
import pandas as pd
from langchain_core.documents import Document
df = pd.read_excel("data.xlsx", sheet_name=None) # 读取所有sheet
documents = []
for sheet_name, sheet_df in df.items():
for idx, row in sheet_df.iterrows():
# 将整行转为字符串作为 page_content
content = ", ".join([f"{col}: {val}" for col, val in row.items()])
metadata = {"source": "data.xlsx", "sheet": sheet_name, "row": idx}
documents.append(Document(page_content=content, metadata=metadata))
这样做可以灵活控制内容和元数据,且不依赖 Unstructured 的较重依赖。对于数据量特别大的 Excel,注意 iterrows() 的性能,可用 to_dict(orient="records") 替代。
📦 10. LangChain 的 Document 对象包含哪些主要属性?(page_content, metadata)¶
Document 是 LangChain 数据流转的基本单元,理解它的属性是入门和排错的关键。
from langchain_core.documents import Document
doc = Document(
page_content="这是一段文本内容",
metadata={"source": "example.txt", "author": "zhangsan", "page": 1},
)
核心属性:
-
page_content: str文档的主要内容,也就是将来被向量化、嵌入、输送给 LLM 的文本。默认是空字符串。 -
metadata: Dict[str, Any]存储文档的附属信息,如来源(source)、页码、作者、日期、标题等。字典中的值可以是简单类型(str, int, float, bool),也可以是复杂的对象,但为了序列化安全,建议使用基本类型。很多检索器会依赖 metadata 进行过滤,所以结构要规范。
其他值得注意的属性和方法(源自 Pydantic 基类):
-
id:可选的唯一标识,在 LangChain 新版本中Document支持id字段,便于去重和更新。 -
type:字符串,默认为"Document",可以用于区分不同类型的文档。 -
model_dump()/json():序列化成字典或 JSON,便于存储和传输。
我的一些实践习惯:
-
始终确保
metadata["source"]存在且有意义,它是召回后引用原文的最重要依据。 -
尽量不要把
page_content的全文复制到 metadata 里,浪费内存和存储。 -
当用分割器生成小块时,分割器会自动添加
start_index等 metadata,我会保留它们,用于后续合并溯源。 -
在把 Document 存入向量库时,有些向量库对 metadata 的过滤有索引要求,我会提前规划哪些元数据字段需要建立过滤索引。
掌握 Document 的属性,就相当于掌握了 LangChain 数据模型的“通用语言”,所有加载器、分割器、检索器都在围绕它工作。
📇 11. metadata 的作用是什么?举例说明你如何在 metadata 中存储文档来源、页码等信息。¶
metadata 是 Document 对象的第二个核心属性(一个普通字典),它的存在让文档从“一坨文本”变成了“带身份证的内容块”。说三个最核心的作用:
① 溯源 (Provenance)
当 LLM 根据检索到的文档生成答案时,我们需要知道这句话出自哪个文件的第几页。metadata 中的 source、page、chunk_id 就是用于这种“引证”的。例如在 RAG 应用里,前端展示的“参考来源”链接,全靠 metadata。
② 过滤与路由 (Filtering & Routing)
向量数据库(如 Pinecone、Weaviate、Milvus)可以利用 metadata 字段做预过滤,比如“只检索来自 2024 年财报的片段”、“只召回技术文档”。这样检索效率和精度都能大幅提升。
③ 上下文管理
把文档的作者、创建时间、语言、版本号等信息带在身上,后续处理(如按时间排序、去重、聚合)就很方便。
存储示例:
from langchain_core.documents import Document
doc = Document(
page_content="人工智能在医疗领域的应用日益广泛...",
metadata={
"source": "reports/medical_ai_2025.pdf",
"page": 3,
"author": "张三",
"created_date": "2025-03-15",
"language": "zh",
"category": "研究报告"
}
)
这个 document 被分割后,子文档也会继承父文档的 metadata(或者由分割器增添 start_index 等字段)。我在构建知识库时会设计一套 metadata 规范,比如要求所有文档都必须有 source 和 doc_id,然后用 Pydantic 模型做校验,防止错传。
🗄️ 12. 如果想从数据库(如 Postgres, MongoDB)中加载数据,LangChain 有没有现成的加载器?¶
有,但覆盖程度不同:
- MongoDB:
MongoDBLoader直接可用。它接受 MongoDB 集合,把每个文档转成 LangChainDocument,page_content来自指定字段,其余字段进入 metadata。
from langchain_community.document_loaders.mongodb import MongodbLoader
loader = MongodbLoader(
connection_string="mongodb://localhost:27017",
db_name="knowledge_base",
collection_name="articles",
field_names=["title", "content"], # 可以组合多个字段作为 page_content
)
docs = loader.load()
-
SQLite:
SQLiteLoader可以加载 SQLite 表中的数据,将每行转换成一个 Document,可以配置内容列和元数据列。 -
PostgreSQL / MySQL 等关系数据库:没有专用的
PostgresLoader,但完全可以自己动手组装。最直接的方式是用SQLAlchemy或psycopg2执行 SQL,将结果转为 pandas DataFrame,再用DataFrameLoader:
import pandas as pd
from langchain_community.document_loaders import DataFrameLoader
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:pass@localhost/db")
df = pd.read_sql("SELECT id, title, content FROM docs", engine)
# 假设用 'content' 列作为 page_content
loader = DataFrameLoader(df, page_content_column="content")
docs = loader.load()
# 会自动把其他列(id, title)放进 metadata
-
这比在加载器内部写 SQL 连接更灵活,也能方便地处理增量更新、字段映射。
-
其他:还有
FirestoreLoader,SupabaseLoader,NotionDBLoader等,都属于“数据库连接器”范畴。
我个人习惯将数据加载逻辑封装成函数,而不是直接用现成加载器,因为生产中要处理连接池、重试、字段校验,这些现成加载器不一定满足。但快速原型时它们很方便。
🔧 13. 如何自定义一个文档加载器?实现 load 方法时需要返回什么?¶
在 LangChain 中自定义加载器,只需要继承 BaseLoader(位于 langchain_core.document_loaders)并实现 lazy_load 方法(返回 Iterator[Document])即可。load() 方法会自动通过调用 lazy_load 并收集结果返回 List[Document]。所以最标准的做法是实现 lazy_load。
from typing import Iterator
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
class MyAPILoader(BaseLoader):
"""从某个 REST API 分页加载文档"""
def __init__(self, api_url: str, api_key: str, page_size: int = 100):
self.api_url = api_url
self.api_key = api_key
self.page_size = page_size
def lazy_load(self) -> Iterator[Document]:
import requests
headers = {"Authorization": f"Bearer {self.api_key}"}
page = 1
while True:
response = requests.get(
self.api_url,
headers=headers,
params={"page": page, "size": self.page_size}
)
response.raise_for_status()
data = response.json()
items = data.get("items", [])
if not items:
break
for item in items:
yield Document(
page_content=item["text"],
metadata={
"source": self.api_url,
"id": item["id"],
"created": item.get("created_at"),
}
)
page += 1
关键点:
-
返回
Document对象,包含page_content和metadata。 -
lazy_load是生成器,可以处理无限数据流,内存友好。 -
建议在
lazy_load内部处理异常,避免迭代中断;对于需要重试的接口,加入 tenacity 等库。 -
如果不想用
lazy_load,也可以直接重写load方法返回List[Document],但就失去了流式处理的好处。
自定义加载器的优势在于:你可以完全掌控数据获取逻辑,比如加入缓存、限流、数据脱敏。
⏯️ 14. 在加载大规模数据(如整个网站)时,如何设计断点续传机制?¶
大规模网站爬取很可能因网络波动、程序崩溃或反爬限制而中断,重头再来的成本太高,所以必须设计状态持久化。
核心思路:在加载过程中定期保存“处理进度”,下次启动时读取进度,跳过已处理的 URL 或页面。
具体实现方案:
- 使用文件/数据库记录已处理集合 用一个 JSON 文件或 SQLite 表,存储已成功处理的 URL 或文档 ID。自定义加载器在遍历时,先加载已处理集合,跳过它们。
import json, os
from typing import Iterator
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
class ResumableWebLoader(BaseLoader):
def __init__(self, urls: list, state_file="state.json"):
self.urls = urls
self.state_file = state_file
self.processed = set()
if os.path.exists(state_file):
with open(state_file) as f:
self.processed = set(json.load(f))
def lazy_load(self) -> Iterator[Document]:
for url in self.urls:
if url in self.processed:
continue
try:
doc = self._fetch_and_parse(url)
yield doc
self.processed.add(url)
# 每处理一个就更新状态文件(可以批量写减少 IO)
with open(self.state_file, "w") as f:
json.dump(list(self.processed), f)
except Exception as e:
# 记录错误但不中断,可加入失败队列重试
pass
-
使用 Redis 等外部存储 在多进程/分布式场景下,用 Redis Set 记录已处理 URL 更好,原子操作,支持并发。
-
结合爬虫框架的中间件 如果使用
RecursiveUrlLoader等内置加载器,它们不支持断点续传。可以自己封装一层:先遍历生成待爬 URL 列表,再用ResumableWebLoader逐个处理。 -
分批加载 + 游标机制 对于 API 分页数据,可以将
last_page或last_id存为状态,下次从该游标继续。
实践心得:
-
状态保存频率要权衡。每处理一条就写磁盘会拖慢速度,可以每 10 条写一次,或在 finally 块统一写。
-
要考虑状态损坏的情况,最好使用原子写入(先写临时文件再 rename)。
-
对于复杂的网站,我通常会先用
scrapy或playwright抓取 HTML 存本地,再用 LangChain 加载本地文件,这样断点续传可以由爬虫框架负责,LangChain 侧只读取文件,简单可靠。
🔤 15. 你如何确保加载的文档编码正确?遇到乱码一般怎么排查?¶
编码问题是我在数据加载阶段碰到的最多、也最烦人的坑之一。经验总结三步走:
① 预防:明确指定编码
对于 TextLoader,养成传 encoding 参数的习惯:
很多国产系统导出的 CSV、TXT 可能是 GBK 或 GB2312,默认 UTF-8 一定乱码。
② 出现乱码时的排查流程:
- 二进制窥探:用 Python 读取文件的前几个字节,看有没有 BOM(如
\xef\xbb\xbf表示 UTF-8 BOM),或有规律的双字节特征(GBK)。
自动检测编码:使用 chardet 库猜测编码。
import chardet
with open("unknown.txt", "rb") as f:
raw = f.read()
result = chardet.detect(raw)
print(result) # {'encoding': 'GB2312', 'confidence': 0.99}
-
然后重新加载:
TextLoader("unknown.txt", encoding="GB2312")。 -
尝试多种编码兜底:如果文件来源复杂,可以写一个 try-except 链条,依次尝试
utf-8,gbk,latin-1,cp1252,但要注意性能。
③ 对于无法完美解码的文件:
-
使用
errors="ignore"跳过无法解码的字节,但可能丢失信息。 -
使用
errors="replace"用�替代,保留位置。 -
如果文件轻微损坏,可以先用
iconv或ftfy库修复。
实战中的小技巧:
-
对大文件,读取前几 KB 来猜编码通常准确度很高,不必全读。
-
如果是 CSV,
pandas.read_csv的encoding参数同样重要,而且它支持更多编码别名。 -
我通常会在加载流程里加一个
encoding_sniffer函数,若用户没指定编码就自动探测,避免反复手工排查。
🖼️ 16. 对于多模态文档(如包含图片和文字的 PPT),LangChain 的处理能力如何?¶
坦白说,LangChain 原生对多模态(图文混合)的支持还比较早期。像 UnstructuredPowerPointLoader 可以提取 PPT 中的文本(标题、正文、表格),但图片通常会被直接忽略,或者只留下一个“占位符”文本。如果我们希望真正理解图片中的信息(图表、流程图、照片),就需要额外手段。
当前可行的方案:
- 先用加载器提取所有文本,再对图片单独处理
可以使用
python-pptx直接提取每个幻灯片的图片,然后调用多模态模型(如 GPT-4o, Claude 3.5 Sonnet)描述图片,再将描述作为文本插入到对应位置。
from pptx import Presentation
import base64
from openai import OpenAI
prs = Presentation("demo.pptx")
docs = []
for i, slide in enumerate(prs.slides):
slide_text = []
# 提取所有文本
for shape in slide.shapes:
if shape.has_text_frame:
slide_text.append(shape.text)
if shape.shape_type == 13: # 图片类型
image = shape.image
# 将图片转为 base64 发给视觉模型
b64_img = base64.b64encode(image.blob).decode()
description = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "请描述这张图片的内容"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_img}"}}
]
}]
).choices[0].message.content
slide_text.append(f"[图片描述]: {description}")
page_content = "\n".join(slide_text)
docs.append(Document(page_content=page_content, metadata={"source": "demo.pptx", "slide": i+1}))
-
使用支持多模态的加载器 LangChain 有个实验性质的
UnstructuredImageLoader,可以结合 OCR 提取图片文字,但对图表语义理解仍然有限。云服务加载器如AzureAIDocumentIntelligenceLoader在解析 PDF/PPT 时可以识别图表并提取部分结构化信息,但仍非完美。 -
未来方向 随着多模态 embedding 模型(如 CLIP)和视觉语言模型的成熟,LangChain 可能会逐步内置处理多模态文档的能力,但目前如果你有大量图文混排的 PPT 或 Word 文档,最好的方式是自己组合工具,先将图片外挂解释,再将解释文本插入原文,构成增强版文档,然后再进行向量化。
个人看法:现阶段 LangChain 的主要优势在文本,多模态部分还属于“高级手工定制”阶段。项目里我一般把 PPT/PDF 当作纯文本处理,除非图片信息确实关键(如产品手册、医学影像报告),才会额外投入多模态能力。
🔗 17. 谈谈 Document Loader 和 Data Connector 的关系,LangChain 为何把它们分开设计?¶
在 LangChain 的早期版本中,这两者界限模糊,所有数据加载器都堆在 document_loaders 里。但从设计哲学演变来看,它们本质上对应两种不同的集成深度:
-
Document Loader:一次性、单向的数据摄取器。它负责把某个数据源的内容“拉”出来,转成 Document 列表,然后就结束使命。典型的如
PyPDFLoader、TextLoader。它们通常无状态,不关注增量变化。 -
Data Connector:持久的、双向的、可能带状态的数据通道。它不仅加载数据,还负责与数据源保持同步、处理权限、实现增量更新、甚至写回数据。例如
AirbyteLoader、NotionDBLoader、StripeLoader、GitHubLoader等,背后往往连接着一个 SaaS 平台或数据库,支持持续同步。
分开设计的原因:
-
职责分离 加载器是“工具”,连接器是“集成”。加载器关注格式解析(PDF、HTML 等),连接器关注通信协议和同步逻辑。强行混合会让接口变得臃肿。
-
依赖管理 Document Loader 的依赖通常较轻(如
pypdf、beautifulsoup4),而 Data Connector 可能需要airbyte、stripe、notion-client等重量级 SDK。如果用户只需要加载本地 PDF,却被迫安装 Notion SDK 是不合理的。LangChain 通过拆分,让用户按需安装langchain-community的扩展。 -
生命周期不同 加载器通常在数据处理 pipeline 中被短暂使用,而连接器可能长期运行在后台服务中,需要监听 webhook、处理 OAuth 刷新等。分开后可以针对这两类场景分别优化。
社区现状:
LangChain 目前把很多 Data Connector 也放在了 langchain_community.document_loaders 下(如 ConfluenceLoader, SharePointLoader),但内部实现更偏向连接器模式。未来可能会抽象出统一的 BaseDataConnector 接口,让加载器成为其一种特化。理解这两者的差异,有助于在架构中做出正确选择:需要一次性批量导入用 Loader,需要持续同步用 Connector + 索引管理 API。
🏗️ 18. 在项目里,你一般把文档加载的逻辑放在哪里?是一次性脚本还是实时服务?¶
这取决于数据源的更新频率和应用场景,我的习惯是混合使用:
① 静态 / 低频更新的知识库 → 一次性脚本或定时批处理
比如公司制度、产品手册、历史档案,可能几周甚至几个月才更新一次。我会写一个独立的 Python 脚本(或 Airflow DAG),每周日凌晨跑一次:
-
从 S3/本地目录加载文件
-
执行加载、分割、嵌入
-
存入向量数据库(覆盖旧索引或增量更新)
-
脚本执行完毕就退出,不在服务里常驻
这种方式简单、可控、资源消耗低,而且很容易和 CI/CD 集成(比如文档更新后触发流水线重建索引)。
② 高频实时数据 → 常驻服务 + 事件驱动
比如用户实时上传的 PDF、客服对话记录、实时日志。通常会在后端 API 服务(如 FastAPI)里,上传文件后同步或异步触发加载与索引更新。一般会:
-
在文件上传接口中,使用
lazy_load流式加载并写入消息队列(Kafka/Redis Stream) -
下游消费者完成分割和嵌入,更新向量库
-
或者使用 LangChain 的
Indexing API+RecordManager,自动管理增量写入和清理
对于这种场景,loader.load() 直接写在请求处理里可能有风险(大文件会阻塞),所以常放在后台任务(Celery / FastAPI BackgroundTasks)中执行。
③ 我的一般原则:
-
加载逻辑要独立于 Web 服务主线程,避免影响响应延迟。
-
能用离线批处理就不用实时加载,减少并发压力。
-
为加载过程添加完整的日志和告警,因为数据加载失败会导致知识库“降智”,比服务宕机更隐蔽。
因此,我很少在关键的在线服务代码里直接调 loader.load(),而是将加载封装成一个可被调用的“数据刷洗 Job”,根据策略调度执行。
🧹 19. 如何对加载后的文档列表进行基本的预处理(如去重、过滤空文档)?¶
加载后的原始 Document 列表往往很“脏”,包含空白页、重复内容、页眉页脚残留等,直接拿去 embedding 会浪费 token 并降低检索质量。我通常会写一个专门的预处理函数,包含以下几个操作:
from langchain_core.documents import Document
from typing import List
import hashlib
def preprocess_documents(docs: List[Document]) -> List[Document]:
seen = set()
cleaned = []
for doc in docs:
content = doc.page_content.strip()
# 1. 过滤空内容或过短内容
if not content or len(content) < 10:
continue
# 2. 基于内容哈希去重(也可用 metadata['source']+page 组合去重)
content_hash = hashlib.md5(content.encode()).hexdigest()
if content_hash in seen:
continue
seen.add(content_hash)
# 3. 可选:去除特定噪声(页眉页脚、版权声明)
content = remove_noise(content) # 自定义函数
# 4. 更新 document 的内容
cleaned.append(Document(page_content=content, metadata=doc.metadata))
return cleaned
常用策略:
-
去重:除了按内容哈希,更稳健的是按
(source, page, chunk_index)组合去重,适用于分块后的文档。直接用 set 可能因 whitespace 差异漏网,可考虑先用re.sub(r'\s+', ' ', content)规范化空白。 -
过滤:长度过滤(太短没信息,太长可能影响检索精度),也可以按语言过滤(如只保留中文/英文)。
-
清洗:用正则删除明显的页眉页脚模式(如“第 x 页”)、电子邮箱、URL(看场景)。我见过有人用一个小型的 NER 模型来删除无关实体,但代价较高。
-
元数据清理:移除不必要或敏感的 metadata 字段,仅保留
source,page,title等,减少向量数据库的存储压力。
注意事项:
-
去重要在分块之前还是之后?如果分块后去重,可能把相似的上下文丢失,我倾向于在加载后、分块前做一次按页/段落的粗粒度去重,分块后不再去重,除非确实存在大量重复块。
-
使用 LangChain 的
DocumentTransformer(如docclean相关)也可以,但目前社区更常用自定义函数,灵活性高。
🚀 20. 如果文档数量极大(百万级别),LangChain 的加载器性能会成为瓶颈吗?如何优化?¶
短回答:会,尤其当使用纯 Python 的单线程加载时。但通过合适的架构和工具选择,完全可以处理百万级文档。
成为瓶颈的原因:
-
解析器(如
PyPDF2、pdfplumber)对复杂 PDF 的解析速度有限,单个大文件可能需要数秒。 -
网络 I/O(加载云存储文件、网页)是主要延迟。
-
默认
load()将所有 Document 对象一次性装入内存,百万文档可能导致 OOM。
优化策略:
① 使用 lazy_load 生成器,避免内存爆炸
用流式方式迭代处理文档,边加载边嵌入、边写入向量库,不保留全量 Document 列表在内存。
② 并行加载(多线程 / 多进程)
IO 密集型操作(读文件、网络请求)适合多线程;CPU 密集型解析(PDF 布局分析)可用多进程。例如:
from concurrent.futures import ThreadPoolExecutor
files = [...] # 百万个文件路径
def load_one(file_path):
loader = PyPDFLoader(file_path)
return loader.load() # 返回该文件的文档列表
with ThreadPoolExecutor(max_workers=20) as executor:
doc_lists = executor.map(load_one, files)
# 然后扁平化处理
注意避免在并行任务中共享有状态的 loader,每个线程创建自己的实例。
③ 批量和增量处理
-
使用 LangChain 的
Indexing API配合RecordManager,只处理新增和修改的文档,避免重复加载。 -
结合数据库存储已处理文件的 hash 或时间戳,实现增量索引。
④ 换用高性能库
-
对于 PDF:
pypdfium2比pypdf快很多;unstructured的 serverless API 可以水平扩展。 -
对于图片类 PDF,可以使用 GPU 加速 OCR 或者云端 OCR 服务并行调用。
-
读文件用
aiofiles异步 IO,配合asyncio提升并发。
⑤ 分布式处理
将加载任务放入消息队列(如 RabbitMQ、Kafka),多个 worker 横向扩展去消费。每个 worker 运行 LangChain 加载器,处理一部分文件。这是生产环境处理百万级数据的最可靠方式。
⑥ 提前转换格式
如果可能,在数据源头把文档统一转成 Markdown 或纯文本,避免运行时解析复杂格式。许多组织有现成的数据预处理平台,LangChain 只需读取轻量的文本文件。
实践经验:我曾处理过一个约 200 万 PDF 的知识库,最终方案是:先用 Spark 集群将 PDF 转换为文本(通过 unstructured 的并行化版本),然后 LangChain 只负责读取文本并嵌入。这样瓶颈转移到了 Spark 集群的弹性计算上,LangChain 的加载器性能反而成了非关键路径。所以,“优化”不止在代码层面,架构层面的分流更加重要。