1. 先说清楚:PDF 进向量库这条链路到底在解决什么问题
做过检索增强类项目的人大概都有体会,真正让人头疼的往往不是模型选型,也不是提示词怎么写,而是“资料怎么进去”。业务方丢过来一个几百页的产品手册 PDF,或者一堆扫描版的技术规范,你需要的不是把它整篇塞进上下文——那既贵又容易丢细节——而是把它切成语义完整的小块,转成向量存起来,等用户提问时按相似度把最相关的几段捞出来。LangChain 在这条链路上提供的就是一套“加载器 + 切分器 + 向量库适配层”的组合件,省掉了大量胶水代码。
这篇内容想讲的就是这条链路:用 LangChain 读取 PDF、把文本切好、选一个向量数据库落库,再完成最基本的相似度检索。适合谁看?如果你已经会写 Python、大概知道 embedding 是什么,但真正上手时卡在“PDF 里的表格读出来是乱的”“切完的块语义断裂”“检索结果驴唇不对马嘴”这些具体问题上,那这篇基本能对上你的场景。我会把每一步的选择理由、参数怎么定、坑在哪里都摊开讲,代码可以直接抄去改。
需要先说明一个前提:PDF 从来都不是为“机器读取”设计的格式,它是为“打印排版”设计的。文字在 PDF 里往往是一堆带坐标的绘制指令,顺序、段落、表格结构全是视觉上的,不是逻辑上的。所以整条链路里,读取环节的损耗往往比后面向量化环节更大。很多人一上来就纠结用哪个向量库、用哪个 embedding 模型,其实方向反了——源头文本质量不行,后面再精致也是白搭。
我个人的经验排序是:解析质量 > 切分策略 > embedding 模型 > 向量库选型。向量库在大多数中小规模场景里其实是最好换的一环,它更像是“存储和索引”的底座,只要接口对得上,迁移成本可控。而解析和切分一旦做错,后面调什么都像是在补救。
2. 环境准备与依赖选型:别一上来就装全家桶
2.1 最小依赖集,先跑通再扩展
LangChain 的生态拆得很细,包名也改过几轮,新手最容易在这一步迷路。我的建议是先装一个够用的最小集,跑通端到端,再按需补。
pip install langchain langchain-community langchain-text-splitters pip install pypdf pdfplumber pip install chromadb pip install sentence-transformers这里每一项的作用要说清楚,不然装完也不知道谁在干活:
| 依赖 | 作用 | 说明 |
|---|---|---|
| langchain-community | 各类 Loader、向量库适配器 | 社区维护的集成层,PDF 加载器在这里 |
| langchain-text-splitters | 文本切分器 | 独立成包,切分策略都在里面 |
| pypdf | PDF 基础文本抽取 | 纯 Python,轻量,速度快 |
| pdfplumber | 表格与精细版面抽取 | 依赖较重,但表格解析能力强 |
| chromadb | 本地向量库 | 零配置起步,适合验证与中小规模 |
| sentence-transformers | 本地 embedding | 免 API 调用,离线可跑 |
关于包版本,有一个很实际的提醒:LangChain 的迭代速度很快,不同小版本之间langchain_community里某些 Loader 的导入路径、参数名可能变过。所以你在网上抄到一段代码跑不通,第一反应不该是“我环境坏了”,而应该先确认版本。我习惯在项目里锁死版本,比如在requirements.txt里写langchain-community==0.2.x这种明确版本号,而不是留>=,否则半年后重装环境,可能整条链路都报错。
注意:不要同时安装
langchain和一堆来源不明的同名衍生包,也不要把不同大版本的langchain-core混在一起。langchain-core是底座,冲突时优先保证它和langchain主包版本匹配。
2.2 向量库怎么选:先看数据量级,再看运维成本
向量库的选择被讨论得太多了,实际上按项目阶段分三档就够了。
| 阶段 | 数据规模参考 | 推荐方案 | 理由 |
|---|---|---|---|
| 验证期 | 几百到几千块 | Chroma 本地持久化 | 零服务、零配置,一个目录就是库 |
| 小规模线上 | 几万到几十万块 | pgvector / Qdrant 单机 | 有成熟运维方案,支持过滤与增量 |
| 大规模 | 百万块以上 | 专用向量服务或分片集群 | 关注索引构建时间与内存占用 |
我踩过的一个坑是:验证阶段用 Chroma 跑得很顺,数据涨到十几万块之后,每次全量重建索引都特别慢,而且进程内存一路攀高。问题不在于 Chroma 不能用,而在于我把“验证期方案”直接带到了“生产期”。所以选型的正确姿势是:先明确当前数据量级和未来半年的增长预期,再决定要不要上服务化方案,而不是一开始就追求“最强”。
还有一个容易被忽略的点:过滤条件。如果你的检索经常要带元数据筛选,比如“只在某个文档类型里找”“只找某一年的内容”,那在选库时就要确认它对 metadata filter 的支持程度。有些轻量方案对复杂过滤支持一般,等到业务提出需求再换库,迁移成本就不只是换个连接串那么简单了。
2.3 embedding 模型:本地还是 API,取决于数据敏感性
这块的取舍很直接。数据敏感、不能出内网,就选本地模型;追求效果、能接受调用成本,就用托管服务。本地模型的显存占用要提前算:一个几百 MB 的模型,加上批处理时的激活值,通常需要预留 2 到 4 GB 显存;如果只有 CPU,速度和并发都要打折。
维度也是要考虑的。768 维和 1024 维在存储成本上差得不算多,但在百万级数据量下,索引内存占用会有明显差别。更重要的是:入库用的模型和查询用的模型必须是同一个。这个错误听起来低级,但真的常见——有人入库时用了一个模型,查询时图省事换了另一个,结果召回质量断崖式下跌,还以为是切分出了问题。
3. PDF 读取:这一层的损耗决定了整条链路的上限
3.1 两种 Loader 的取舍与实战差异
LangChain 里最常用的是PyPDFLoader,它基于 pypdf,把每页抽成一段文本,并带上page元数据。优点是快、依赖轻;缺点是遇到多栏排版、表格、页眉页脚时,抽出来的文本顺序会乱。
PDFPlumberLoader走的是另一条路,它把 PDF 里的字符和坐标都解析出来,能更好地处理表格和分栏。代价是速度慢一截,依赖也重。
我的实际做法是分两步:先用 PyPDFLoader 快速过一遍,抽样几页看文本质量;如果发现表格错乱严重,再针对性换 PDFPlumberLoader 或者对特定页做单独处理。不要一上来就对几百页文档全量跑重解析器,时间成本不划算。
from langchain_community.document_loaders import PyPDFLoader, PDFPlumberLoader loader = PyPDFLoader("manual.pdf") docs = loader.load() print(len(docs)) print(docs[0].page_content[:500]) print(docs[0].metadata)跑完先别急着往下走,metadata里通常会有source和page,这两个字段后面做溯源引用非常关键。建议从一开始就在切分后保留它们,否则用户问“这句话出自哪一页”,你答不上来,体验会大打折扣。
3.2 清洗:比换 Loader 更有效的动作
很多时候文本质量差的根源不是 Loader 不行,而是没做清洗。PDF 抽出来的文本常见几类噪声:页眉页脚重复出现、页码单独成行、断行把一个句子切成两半、连字符断词(比如informa-\ntion)、多余的空格和制表符。
处理思路很朴素,但效果明显:
import re def clean_text(text: str) -> str: # 合并被硬换行切断的句子 text = re.sub(r"(?<=[a-z,])\n(?=[a-z])", " ", text) # 修复连字符断词 text = re.sub(r"(\w)-\n(\w)", r"\1\2", text) # 压缩多余空白 text = re.sub(r"[ \t]{2,}", " ", text) # 去掉单独成行的页码 text = re.sub(r"\n\s*\d+\s*\n", "\n", text) return text.strip()注意:正则清洗一定要先在样本上验证,别直接全量跑。尤其是“去掉单独成行的数字”这条,如果正文里有独立成行的数据行,会被误删。我一般会先抽取 5 到 10 页,把清洗前后对比打印出来看,确认没有误伤再批量处理。
页眉页脚的去除更麻烦一点,因为每份文档的页眉文字不一样。一个实用技巧是:统计所有页里出现频率极高、且位置固定的短行,把它们当作页眉页脚候选批量删掉。比如某个字符串在超过 80% 的页里都出现在前两行,基本可以判定是页眉。
3.3 表格:绕不过去的老大难
表格是 PDF 解析里最麻烦的部分,我的态度比较务实:不要指望通用解析器把表格还原成结构化数据,除非表格是核心业务数据。
如果表格只是辅助说明,把表格区域整块抽成文本,用行内分隔符拼起来就够了,检索时靠上下文语义仍然能命中。如果表格是关键数据(比如参数表、价目表),那就要单独处理:用 pdfplumber 的extract_tables()拿到二维数组,再序列化成“列名: 值”的形式写进文档块,这样检索时语义更清楚。
import pdfplumber with pdfplumber.open("manual.pdf") as pdf: page = pdf.pages[10] tables = page.extract_tables() for table in tables: for row in table: line = " | ".join(cell or "" for cell in row) print(line)一个经验:表格抽出来后,给每一行补上表头是提升检索质量的高性价比动作。原始表格第一行是表头,后续每行数据本身缺少列名信息,单独拿出来向量化会丢失语义。把它拼成型号: X200, 电压: 220V, 功率: 800W这种形式,召回准确率会明显提升。
4. 文本切分:切得好不好,直接决定召回准不准
4.1 为什么必须切,以及切多大的判断依据
不切行不行?技术上可以,但实践上不行。原因有两层:一是 embedding 模型有输入长度上限,超长文本会被截断,等于后面的内容白存;二是即便模型能吃得下,一整篇文档的向量会变成一个“平均语义”,检索时既不够精确,也浪费上下文窗口。
切多大?常见建议是 500 到 1000 字符,重叠 100 到 200。但这个数字不是拍脑袋来的,它取决于两件事:你的 embedding 模型的有效语义窗口,以及你的问题粒度。
如果用户的问题很细,比如“这个设备的额定电压是多少”,那切小一点(300 到 500 字符)更有利,块里噪声少,向量更聚焦。如果用户的问题偏概括,比如“这个产品的整体设计思路”,那切大一点(800 到 1200 字符)更好,块里有足够上下文。
我的做法是先按 800 字符、重叠 150 起步,拿二三十个真实问题测召回,看命中块里有没有“差一点就命中”的情况——如果经常出现答案就在相邻块里,说明切得偏小或重叠不够;如果命中块里大段无关内容,说明切得偏大。
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=150, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = splitter.split_documents(docs) print(len(chunks))RecursiveCharacterTextSplitter的逻辑值得说透:它不是按固定长度硬切,而是按分隔符列表的顺序,优先在“语义边界”处切。先找双换行(段落),找不到再找单换行,再不行才按句号、分号,最后才落到空格甚至字符级。这个设计的好处是尽量不把一个完整句子劈开。
注意:
separators列表要根据你的文档语言调整。中文文档里,句号、问号、分号这些分隔符必须显式加上,否则切分器会退化成按空格切,而中文文本里空格很少,结果就是硬切,语义断裂严重。这是中文场景下最常见的配置错误之一。
4.2 重叠的作用与过度重叠的代价
重叠 (chunk_overlap) 的意义是防止关键信息正好落在切口上导致丢失。比如一句话的前半段在块 A 末尾、后半段在块 B 开头,有重叠的话两个块里都有完整句子,检索时就更容易命中。
但重叠不是越大越好。重叠 150 意味着每个字符平均被存两次左右,存储和索引成本上升;重叠过大(比如 chunk_size 的 50% 以上)还会导致检索结果里出现大量高度相似的重复块,挤占上下文位置,反而降低有效信息密度。
我的经验值:重叠控制在 chunk_size 的 15% 到 20%。800 的块配 150 的重叠,基本就是这个区间。如果发现相邻块内容高度重复影响检索,可以考虑在入库前做一次近似去重。
4.3 按结构切:比固定长度更聪明的做法
如果文档结构清晰(有明显的章节标题、条款编号),按结构切会明显优于按长度切。LangChain 提供了MarkdownHeaderTextSplitter,对 Markdown 格式的文档很友好;对普通 PDF,如果抽出来的文本带有明显的编号层级(比如“3.2.1”),也可以用正则先做一次结构化预处理。
思路是:用标题或编号作为锚点,把文档先拆成“章节级”的大块,再对过大的章节做二次切分。这样做的好处是每个块的语义边界和文档本身的逻辑边界一致,检索命中后的上下文也更容易理解。
一个更实用的增强手段是给每个块补上“面包屑”:在块内容前面拼接上它所属的章节路径,比如[第三章 参数说明 > 3.2 电气参数] 原文内容...。这样即使是小切口,块本身也带有归属信息,向量表达更完整。这个技巧在多层级文档上效果很好,成本几乎为零。
5. 向量化与入库:参数背后的账要算清楚
5.1 从文本到向量的那一步
拿到切好的块之后,就是调 embedding 模型把文本转成向量。用本地模型的话,HuggingFaceEmbeddings是比较直接的入口。
from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True}, )normalize_embeddings=True这个参数值得单独说:归一化之后,向量内积等价于余弦相似度,很多向量库的默认距离度量就是按这个前提设计的。如果不归一化,用内积检索时,长文本的向量模长更大,会系统性地占优势,导致召回偏向长块。这个坑比较隐蔽,表现是“检索总是返回很长的块”,排查半天才发现是归一化没开。
批次大小也是要调的。批量太大容易爆内存,太小则吞吐上不去。CPU 环境下一般 16 到 32 一批比较稳妥,有 GPU 可以提到 64 甚至更高,但要关注显存峰值。
5.2 入库与持久化:把库当成有状态的东西对待
from langchain_community.vectorstores import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", collection_name="manual_v1", )这段代码跑完,向量的确落盘了,但有几个点必须心里有数。
第一,collection_name要显式命名并带版本号。我第一次做的时候用了默认集合名,后来文档更新重新入库,新旧数据混在一个集合里,检索结果新旧夹杂,排查了很久。带上版本号(比如manual_v1、manual_v2),更新时写新集合,验证没问题再切流量,回滚也方便。
第二,入库前要做幂等设计。同一份文档重复入库会产生大量重复块,检索时同一段内容反复出现。稳妥做法是给每个块生成稳定 ID,比如hash(文档ID + 页码 + 块序号),入库时用 upsert 语义而不是无脑 add。
第三,元数据里除了source和page,建议再补上doc_id、section、ingest_time。后面做“只看某份文档”“只看最新版本”这类过滤时,没有这些字段会很被动。
关于持久化,还有个细节:Chroma 的持久化目录不是“随便拷贝就能用”的,进程运行中对目录做拷贝可能拿到不一致状态。备份应该在进程停止或者明确 flush 之后做。
6. 检索环节:把召回质量真正调上去
6.1 相似度检索的基本形态与 top_k 的取舍
retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) results = retriever.invoke("设备的额定电压是多少") for doc in results: print(doc.metadata.get("page"), doc.page_content[:120])k取多少?这其实是个权衡:k 太小容易漏,k 太大则把无关内容塞进上下文,既增加成本又可能干扰生成。我的起步值是 4 到 6,然后看效果调整。判断依据是:如果正确答案经常排在第 7、第 8 位才出现,说明要么 k 太小,要么前面的块排序有问题。
顺带说一个常被忽视的动作:看排序,不要只看有没有命中。检索质量的关键指标是“正确块排在前面的比例”,而不是“正确块在不在结果里”。因为下游生成的上下文窗口有限,排在后面的块很可能根本进不去。
6.2 混合检索:关键词与向量的互补
纯向量检索有个固有短板:对精确匹配不敏感。用户问一个具体型号X200-Pro,向量检索可能返回一堆语义相近但型号不同的块。这时候关键词检索(BM25 之类)就能补上。
LangChain 提供了EnsembleRetriever来做加权融合:
from langchain_community.retrievers import BM25Retriever from langchain.retrievers import EnsembleRetriever bm25 = BM25Retriever.from_documents(chunks) bm25.k = 4 vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) ensemble = EnsembleRetriever( retrievers=[bm25, vector_retriever], weights=[0.4, 0.6], )权重的取法没有标准答案。如果业务问题里型号、编号、专有名词多,关键词权重要高一些;如果是自然语言描述型问题,向量权重更高。我一般从 0.4/0.6 起步,用一批标注过的问题跑召回率做对比。
注意:BM25 是在内存里对全量块建索引的,块数量到十万级以上时,初始化时间和内存占用都要评估。数据量大的话,优先考虑向量库自带的关键词能力或者独立检索服务,而不是硬扛内存 BM25。
6.3 用元数据过滤缩小搜索范围
当文档库里有多个来源、多个版本时,先过滤再检索几乎总能提升效果。比如限定只查doc_id="manual"且version="v2"的数据,搜索空间小了,误召回自然减少。
这件事的关键不在代码,而在元数据设计要提前做。很多人是先入库、后想过滤,结果发现元数据里只有source和page,想按业务维度过滤根本做不到,只能重建。所以我在切分那一步就会把能想到的过滤维度都写进metadata,宁可多存几个字段。
6.4 重排:成本不高但收益明显的一步
初检拿到 k=20 个候选,再用一个重排模型(cross-encoder 类)精排出前 4 个,这个模式在实战中很常见。重排模型对“查询-文档”对做联合编码,精度比纯向量相似度高,代价是计算量更大,所以只对少量候选做。
LangChain 里有ContextualCompressionRetriever配合各类 reranker 的用法。如果不想引入额外模型,也可以用简单的规则重排,比如对命中关键词的块加权、对包含标题路径的块加权,效果虽然不如模型重排,但几乎没有成本。
7. 常见问题与排查实录
7.1 问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 检索几乎不命中 | 入库与查询用了不同 embedding 模型 | 核对两处模型名与版本 |
| 命中块内容残缺、句子断裂 | 切分过小或分隔符配置不当 | 打印块首尾 100 字检查断裂点 |
| 总是返回很长的块 | 未做向量归一化 | 检查normalize_embeddings |
| 检索结果重复度高 | 重叠过大或重复入库 | 检查 overlap 与幂等逻辑 |
| 表格内容答非所问 | 表格被抽成乱序文本 | 改用 pdfplumber 并补表头 |
| 特定型号查不到 | 纯向量对精确匹配弱 | 加关键词检索做混合 |
| 页码引用对不上 | 未保留page元数据 | 检查切分后 metadata 传递 |
| 重新入库后结果新旧混杂 | 集合名未带版本 | 分集合入库、验证后切流量 |
7.2 我踩过的三个坑
第一个坑是切分器语言配置错误。当时处理一份中文技术文档,直接用了默认分隔符列表,结果整篇按空格硬切,块里全是半句话。表现是检索能命中但生成的回答总是缺胳膊少腿。改成分隔符里显式加入中文标点之后,问题立刻消失。这个坑的隐蔽之处在于:代码不报错,检索也有结果,只是质量差,很容易被误判成模型不行。
第二个坑是增量更新没有幂等。文档更新后我重新跑了入库脚本,结果旧版本数据还在集合里,检索时新旧内容同时被召回,回答自相矛盾。后来改成按doc_id先删后插,配合集合版本号,才彻底解决。
第三个坑是top_k 调太大反而变差。有一阵我为了“保证不漏”,把 k 调到了 15,结果上下文里塞了大量弱相关内容,生成质量不升反降。这让我意识到检索的目标不是“把所有可能相关的都拿到”,而是“把最相关的排在最前面”。
7.3 效果怎么验证:别靠感觉
评估这件事必须量化,否则调参就是玄学。我的做法是维护一个几十条的小评测集:每条包含一个真实问题和它对应的正确答案所在位置(文档 + 页码)。然后用两个指标看:
- 命中率:正确答案是否出现在前 k 个结果里;
- 排名质量:正确答案的平均排名是多少。
这两个数字在一次参数调整前后的变化,比任何主观感受都可靠。评测集不需要很大,二三十条就能看出趋势,关键是问题要来自真实使用场景,而不是自己编的“理想问题”。
8. 上线前值得再确认的几件事
真要把这条链路跑在线上,还有几个容易漏的点。
其一是解析与切分的可复现性。文档更新后重新处理,如果切分参数变了,块的边界就会变,之前基于块 ID 做的任何引用都可能失效。所以切分参数要写进配置并记录版本,和集合版本对应起来。
其二是失败处理。几百页的 PDF 里偶尔会有几页解析出空文本,尤其是扫描件或者纯图片页。这类页面要么走 OCR 补,要么明确标记为“无文本”跳过,不要让空块进库——空块向量没有意义,还会污染检索结果。
其三是成本监控。如果 embedding 走的是托管服务,入库时的调用量往往远超预期,因为切分会让文本总量膨胀一到两倍。上线前先把块数、平均块长度算出来,乘一下预估成本,心里有个数。
其四是上下文窗口的分配。检索出来的块最终要拼进提示词,块越多、越长,留给对话历史和指令的空间就越少。我的习惯是给检索内容设一个字符上限,超出就按排名截断,保证提示词结构稳定。
最后一个体会是,这条链路的每一环都可以单独测、单独调,千万不要把“效果不好”当成一个整体问题去猜。先看检索结果里有没有正确答案——没有,问题在解析、切分或 embedding;有但排名靠后,问题在排序或混合策略;排名靠前但生成答错,那才轮到提示词和模型的问题。分层定位,比反复换模型有效得多。