ChatPDF 这类工具我第一次接触,是在帮一个做审计的朋友处理一份两百多页的合同附件。他当时的诉求特别朴素:能不能直接问“这份合同里关于违约金的条款在第几页、写了什么”,而不是一页页翻。后来我自己动手复现了一套,从解析、切分、向量化到检索生成全部走了一遍,踩的坑比想象中多得多。这篇文章就把这套东西从里到外拆开讲清楚,包括它到底解决了什么问题、核心链路每一步在做什么、怎么用几百行代码跑出一个能用的版本,以及那些文档里不会写、只有实际跑过才知道的细节。适合两类人看:一类是想搞清楚这类工具原理的产品和技术同学,另一类是打算自己搭一套内部文档问答系统的开发者。哪怕你只是重度使用者,看完也能明白为什么它有时候答得准、有时候答得离谱。
1. 先想清楚:这个需求到底真不真
1.1 从“翻页找答案”到“直接问答案”的落差在哪
传统 PDF 阅读体验的核心矛盾,是信息密度与检索效率不匹配。一份几十页的技术白皮书,你真正需要的可能就三段话,但要定位这三段话,得先看目录、再翻章节、再扫小标题,遇到扫描件连目录都点不动。人在这个过程中消耗的不是阅读能力,是定位成本。
ChatPDF 这类工具切的正是这个位置。它不改变文档本身,也不做知识管理,只做一件事:把你用自然语言描述的问题,映射到文档里最相关的那几段文字上,然后组织成一句人话回答你。这个动作看起来简单,但它把“检索”和“阅读”两件事拆开了——检索交给机器,阅读交给你判断。
我见过太多人一开始把它当成“文档摘要神器”,结果发现问全局性问题时效果一般。原因很简单:摘要需要通读全文,而这类工具的底层是局部检索,它每次只看到文档的一小片。理解这个前提,你对它的预期就不会跑偏。
1.2 它的技术底子其实就是一套特化的 RAG
抛开产品包装,ChatPDF 的内核是检索增强生成(RAG)在单一文档场景下的收敛版本。通用 RAG 面对的是一个不断增长的语料库,要处理权限、增量更新、多租户;而单文档问答把范围压缩到了一篇文件,索引规模小、生命周期短、可以随传随建,这让工程复杂度下降了一个量级。
但收敛不等于简单。恰恰因为范围小,用户对准确率的容忍度反而更低——他会默认“文档就在那儿,你怎么会答错”。通用知识问答里模型说错一句,用户可能觉得是知识盲区;文档问答里模型说错一句,用户立刻判定这工具不能用。所以这类产品的技术重心,其实全压在解析质量和召回精度这两件最不性感的事情上。
我把整条链路拆成五段,后面每一段都会展开:
| 环节 | 核心任务 | 最容易出问题的地方 |
|---|---|---|
| 解析 | PDF 二进制转结构化文本 | 双栏、表格、扫描件、页眉页脚 |
| 切分 | 长文本拆成可检索单元 | 切断语义、丢掉上下文 |
| 向量化 | 文本转向量并建索引 | 模型与语种不匹配、归一化遗漏 |
| 检索 | 找出最相关的若干块 | 纯向量召回漏掉专有名词 |
| 生成 | 基于上下文组织答案 | 幻觉、越界回答、无引用 |
1.3 哪些文档适合,哪些纯属给自己找麻烦
不是所有 PDF 都值得喂进去。我按实测经验分了个类:
效果很好的:产品手册、规章制度、技术规范、会议纪要导出件、论文正文、招投标文件的条款部分。这类文档结构清晰、语言规范、答案通常集中在一两段里。
效果一般的:财报、带大量合并单元格的统计表、图纸附注。表格一旦跨页或者单元格嵌套,解析出来就是一团乱麻,检索到的片段人看着都费劲,模型更没法用。
不建议用的:手写扫描件、印章覆盖严重的合同、公式密集的理工教材、需要逐字精确引用的法律条文(这类场景应该用全文检索加人工核对,不能让模型转述)。
提示:如果你手上是扫描件,先确认 OCR 质量再谈问答。OCR 错一个字,向量就偏一截,后面所有环节都在错误的地基上盖楼。
2. 五段链路逐一拆解:每一环的取舍逻辑
2.1 解析层:PDF 根本不是文本文件
这是最多人低估的一环。PDF 的本质是一堆“在某个坐标画某个字形”的绘图指令集合,它不存储段落、不存储阅读顺序、不存储表格结构。你用阅读器看到的两栏排版,在文件里可能只是两串坐标交错的字符流。
我最初用最朴素的方式提取文本,结果一篇双栏论文读出来是“左栏第一行 + 右栏第一行 + 左栏第二行”这样交替的乱序。检索的时候向量自然全歪。后来改成按文本块的边界框(bbox)做栏检测:先取出所有块的坐标,按 x 轴中点把页面分成左右两半,再各自按 y 排序,顺序就对了。这个改动不大,但召回准确率的提升非常明显。
表格是第二个大坑。纯文本抽取会把表格拍平成“单元格内容按行拼接”,列与列的关系彻底丢失。我的处理策略是分级:能用规则识别的简单表格,转成 Markdown 表格保留结构;识别不了的复杂表格,直接把整块区域的截图连同图注一起存下来,在检索命中时以图片形式返回给用户看,而不是硬转成文字。这个“转不了就退回原图”的思路,比强行解析然后输出一堆错位数字要诚实得多。
页眉页脚、水印、页码是第三个隐性污染源。它们会在每一页重复出现,导致向量空间里大量高度相似的噪声块,检索时很容易把“第 3 页 内部资料 请勿外传”这种内容排到前面。我在切分前做了一次全局统计:出现在超过 60% 页面且位置靠近上下边缘的短文本块,直接剔除。
2.2 切分层:chunk 策略直接决定召回上限
切分是整个链路里最像“手艺活”的一步。切太大,一个块里混了三四个主题,向量被平均掉,检索时谁的边都沾不上;切太小,一句话被拦腰截断,模型拿到半句话根本答不出来。
中文场景我一般用字符数而不是 token 数来控制窗口,因为中文分词器差异大,字符数更稳定。实测下来,正文类的块控制在 300 到 500 字之间比较舒服,重叠 10% 到 15%。重叠的作用是防止关键句正好落在切口上——虽然浪费一点存储,但能救回不少边界情况。
比固定窗口更好用的是父子块结构:用小块(200 字左右)建索引做检索,命中后把它所属的大块(1000 字左右)喂给模型。这样检索的精度靠小块保证,生成的上下文完整性靠大块保证,两边的好处都占上了。这个技巧我在好几份长文档上验证过,尤其是那种“问题问的是细节,但答案需要前后两段才能说清”的情况,效果差距肉眼可见。
如果文档有明确的标题层级,优先按结构切。按“一级标题—二级标题—段落”切出来的块自带语义边界,比机械按字数切好太多。代价是要写一套能识别标题的规则,通常靠字号、加粗、编号模式(如“第三章”“3.1”)组合判断,准确率能做到八成以上,剩下两成靠兜底。
2.3 向量化与索引:模型选型没有想象中玄学
向量模型的选择,我的判断顺序是:语种匹配 > 维度成本 > 榜单排名。中文文档就必须用中文语料训练过的模型,拿一个纯英文模型去编码中文,出来的是“看起来有向量但语义几乎随机”的结果,这个坑我踩过一次,排查了大半天才怀疑到模型头上。
维度方面,768 维和 1024 维在检索效果上的差距,远小于它们在内存和计算时间上的差距。一份 300 页的文档切完大概两三千个块,用 1024 维存成 float32,光向量就占十几兆,加上索引结构翻倍。如果只是单文档问答,这个量级无所谓;但如果你要做多文档同时在线,维度就得抠一抠。
索引结构上,几千个块用暴力内积检索(IndexFlatIP)完全够,检索一次几毫秒,还不用担心近似算法的召回损失。超过十万块再考虑 HNSW 之类的近似索引。
关于归一化,这里有个特别容易犯的错:如果你的向量模型输出的是未归一化向量,用内积检索等价于带模长的余弦,长文本块的向量模长往往更大,会导致“越长越容易被检索到”的系统性偏差。稳妥做法是先做 L2 归一化,再统一用内积。
2.4 检索与重排:只做向量召回一定会翻车
纯向量检索有个天然短板:它擅长语义相似,不擅长精确匹配。用户问“型号 XJ-2200 的额定功率是多少”,向量很可能召回一堆讲“功率参数”的段落,但就是漏掉真正写着 XJ-2200 的那一段,因为型号这种字符串在语义空间里几乎不携带信息。
解决办法是混合检索:向量召回一路,BM25 关键词召回一路,两路结果用倒数排名融合(RRF)合并。RRF 的好处是不需要调权重,只依赖排名顺序,工程上特别省心。我实测下来,加了 BM25 之后,涉及编号、型号、人名、专有名词的问题召回率提升非常明显。
融合之后再做一次重排。重排用的是交叉编码器,把问题和候选块拼在一起送进模型打分,精度比双塔向量高不少,代价是慢。所以流程上先粗排取 20 到 30 个候选,重排后只留 3 到 5 个进生成,这样既保证精度又控制耗时。
注意:重排模型的大小要和你的响应时间预算匹配。如果你要求端到端两秒内出结果,就别上太大的重排模型,宁可多召回几个块让生成模型自己筛。
2.5 生成与引用:让答案可溯源才敢用
最后一步是把检索到的片段和用户问题拼成提示词。这里的关键不是“写一个多花哨的提示词”,而是把约束写死在提示里:只允许使用给定材料回答、每个结论必须标注来源页码、材料中没有就明确说没有、不要基于常识补充。
我最初偷懒没加“必须标注页码”,结果用户看到答案第一反应是“真的假的”,信任成本极高。加上引用之后,用户点一下就能跳到原文核对,接受度完全不一样。引用还有一个隐性好处:它逼着模型在生成时对照材料,间接压低了幻觉率。
另一个细节是上下文顺序。多个块拼进提示词时,把重排得分最高的放在最前面和最后面(模型对首尾位置更敏感),中间放次要的,这个小调整对答案完整度有帮助。
3. 动手实现:一套能跑起来的最小方案
3.1 环境与依赖选型
我用的组合是:PyMuPDF 做页面级解析(速度快、能拿到坐标),pdfplumber 作为表格场景的补充(表格线检测更准),sentence-transformers 加载向量模型,FAISS 建索引,rank_bm25 做关键词召回,生成端调一个支持长上下文的模型接口。
选 PyMuPDF 而不是别的,主要是因为它能一次拿到所有文本块的边界框,做栏检测和页眉页脚剔除都靠这个。纯文本抽取的库拿不到坐标,后面所有清洗都没法做。
pip install pymupdf pdfplumber faiss-cpu sentence-transformers rank-bm25 numpyCPU 就够,不需要显卡。一份百页文档从解析到建完索引,普通笔记本大概两三分钟,瓶颈主要在一开始的 OCR 和后面的 embedding。
3.2 解析模块:栏检测与噪声剔除
先把页面拆成块并拿到坐标,然后按 x 轴做栏判断:
import fitz from collections import Counter def extract_blocks(pdf_path): doc = fitz.open(pdf_path) pages = [] for pno, page in enumerate(doc): raw = page.get_text("blocks") blocks = [] for b in raw: x0, y0, x1, y1, text, bno, btype = b if btype != 0: # 只保留文本块 continue text = text.strip() if not text: continue blocks.append({ "text": text, "x0": x0, "y0": y0, "x1": x1, "y1": y1, "page": pno + 1, }) pages.append(blocks) return pages接着做双栏判断。思路是看整页文本块的水平中心分布是否呈现两个簇:
def is_two_column(blocks, page_width): mid = page_width / 2 left = sum(1 for b in blocks if (b["x0"] + b["x1"]) / 2 < mid) right = len(blocks) - left if len(blocks) < 6: return False # 两侧都有足够多的块,且中间区域几乎没有跨栏块 crossing = sum( 1 for b in blocks if b["x0"] < mid - 20 and b["x1"] > mid + 20 ) return left >= 3 and right >= 3 and crossing <= len(blocks) * 0.2判定为双栏后,左边一列按 y 排序拼接,再拼右边一列;单栏就直接按 y 排序。这一步做完,双栏论文的阅读顺序就正常了。
页眉页脚剔除放在所有页面解析完之后统一做,用频次统计:
def drop_repeated_blocks(pages, ratio=0.6, edge=60): counter = Counter() for blocks in pages: seen = set() for b in blocks: key = b["text"][:40] if key in seen: continue seen.add(key) counter[key] += 1 n = len(pages) result = [] for blocks in pages: kept = [] for b in blocks: key = b["text"][:40] near_edge = b["y0"] < edge or b["y1"] > 780 if counter[key] / n > ratio and near_edge and len(b["text"]) < 60: continue kept.append(b) result.append(kept) return result表格单独处理:先用 pdfplumber 检测有边框的表格区域,能抽出规整的就转成 Markdown,抽出来行列明显错位的,就把这块区域的坐标记下来,后面渲染成图片存着,供引用时展示原图。
3.3 切分与索引构建
按段落聚合到目标长度,再留重叠:
def build_chunks(blocks, target=420, overlap=60): chunks, buf, buf_len = [], [], 0 for b in blocks: t = b["text"] if buf_len + len(t) > target and buf: chunks.append({ "text": "\n".join(x["text"] for x in buf), "page": buf[0]["page"], "page_end": buf[-1]["page"], }) # 保留尾部作为重叠 tail, tail_len = [], 0 for x in reversed(buf): tail.insert(0, x) tail_len += len(x["text"]) if tail_len >= overlap: break buf, buf_len = tail, tail_len buf.append(b) buf_len += len(t) if buf: chunks.append({ "text": "\n".join(x["text"] for x in buf), "page": buf[0]["page"], "page_end": buf[-1]["page"], }) return chunks建索引时记得归一化:
import numpy as np, faiss from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-base-zh-v1.5") texts = [c["text"] for c in chunks] emb = model.encode(texts, batch_size=32, normalize_embeddings=True, convert_to_numpy=True).astype("float32") index = faiss.IndexFlatIP(emb.shape[1]) # 归一化后用内积 index.add(emb)同时在切分结果上建一份 BM25 索引,中文需要先做简单的字级切分(对中文来说,按单字或二元组切分的效果通常够用):
from rank_bm25 import BM25Okapi tokenized = [list(t) for t in texts] # 字级切分,够用且不需要词表 bm25 = BM25Okapi(tokenized)3.4 检索、融合与生成的完整流程
def search(query, top_vec=20, top_bm25=20, final_k=4): q = model.encode([query], normalize_embeddings=True, convert_to_numpy=True).astype("float32") _, idx_vec = index.search(q, top_vec) vec_ids = idx_vec[0].tolist() scores = bm25.get_scores(list(query)) bm25_ids = np.argsort(scores)[::-1][:top_bm25].tolist() # RRF 融合 k = 60 fused = {} for rank, i in enumerate(vec_ids): fused[i] = fused.get(i, 0) + 1 / (k + rank + 1) for rank, i in enumerate(bm25_ids): fused[i] = fused.get(i, 0) + 1 / (k + rank + 1) merged = sorted(fused.items(), key=lambda x: -x[1])[:20] # 重排(这里用交叉编码器) from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") pairs = [(query, chunks[i]["text"]) for i, _ in merged] rs = reranker.predict(pairs) order = np.argsort(rs)[::-1][:final_k] picked = [merged[o][0] for o in order] return [chunks[i] for i in picked]生成端的提示词我一般这样组织,重点是把约束写死:
PROMPT = """你是一个文档问答助手。请严格依据下面提供的材料回答问题。 规则: 1. 只使用材料中的信息,不要引入材料之外的常识。 2. 每个结论后面用 [第X页] 标注来源。 3. 材料中没有相关信息时,直接回答"文档中未找到相关内容"。 4. 回答控制在 300 字以内,条理清晰。 材料: {context} 问题:{question} """context的拼接顺序按重排得分排:最高分放最前,次高分放最后,其余按顺序放中间。这个细节对答案完整度的帮助,比换一个更大的模型来得实在。
3.5 几个关键参数怎么定
参数不是拍脑袋,我按实际文档量级算一笔账。假设一份 100 页的中文技术文档,每页正文约 600 字,总计 6 万字。按 420 字一块、60 字重叠计算,块数约为 60000 / (420 - 60) ≈ 167 块。
| 参数 | 取值 | 依据 |
|---|---|---|
| chunk size | 400 字左右 | 一个完整论点通常 200 到 500 字 |
| overlap | 10% 到 15% | 覆盖切口,成本可接受 |
| 向量召回 top-k | 20 | 给重排留足候选 |
| 关键词召回 top-k | 20 | 与向量路数量对等,避免单边主导 |
| 重排后入生成 | 3 到 5 | 上下文控制在 2000 到 3000 字 |
| RRF 常数 k | 60 | 标准取值,实测没必要细调 |
成本方面,167 个块的向量化,用本地模型在普通 CPU 上大约半分钟到一分钟,没有调用费用。如果换成调用接口做 embedding,6 万字按中文大致一比一的 token 折算,一次索引成本在几毛钱量级,但每次重建都要付,所以文档量大时本地模型更划算。生成端才是真正花钱的地方:每次提问送 2000 到 3000 字上下文加问题,回答三四百字,单次调用成本很低,但如果一天几百次问答,就要考虑加一层缓存,把高频问题的答案存下来。
4. 效果调优:从“能答”到“答得准”
4.1 先造评测集,别凭感觉调参
调优最忌讳的就是“随便问几个问题觉得还行就上线”。我的做法是花一小时手工造 50 条评测样本,每条包含问题和它在文档中的标准答案出处(页码 + 关键句)。然后定义两个指标:
- 召回命中率:标准答案所在的块,有没有出现在检索结果的前 5 名里。这个指标只看检索,不看生成。
- 答案正确率:生成的答案是否包含了标准答案里的关键信息点。
这两个指标要分开看。如果召回命中率高但答案正确率低,问题在生成端,改提示词或者调整上下文;如果召回命中率本身就低,那后面怎么调生成都没用,得回头改解析和切分。我一开始把所有问题混在一起看,结果一直在调提示词,实际上真正的病根在解析阶段把两栏顺序搞错了。
50 条样本听起来少,但足够暴露系统性问题。我一般会把失败案例归类,如果同一类问题连续出现三次以上,才算真的需要改。
4.2 常见 badcase 与对策对照
| 现象 | 大概率原因 | 对策 |
|---|---|---|
| 回答“未找到相关内容”但文档明明有 | 切分把答案切断了,或召回没命中 | 加重叠、上父子块、加 BM25 路 |
| 答得很泛,不涉及具体数字 | 上下文块太大,模型抓不住重点 | 缩小 chunk、提高重排权重 |
| 数字答错 | 表格解析错位,或跨页表被拆开 | 表格区域改存图片,按整块检索 |
| 型号、编号类问题答不出来 | 纯向量召回漏掉精确串 | 必加 BM25,权重上做倾斜 |
| 引用了不存在的页码 | 模型自己编的 | 提示词强约束 + 后处理校验页码范围 |
| 同一个问题两次答案不一样 | 温度太高或检索结果不稳定 | 生成温度调低,检索结果固定排序 |
4.3 一个具体的调优案例
有份产品规格书,用户频繁问“不同型号之间的差异”。一开始的做法是每个型号一段,切分成独立块。结果问“A 型和 B 型有什么区别”时,检索只召回了 A 型那一段,模型就只答了 A 型,另一边的信息完全缺失。
问题出在两段信息在物理上不相邻,向量相似度又各自只对问题的一半敏感。我改了两处:一是把规格对比表整体作为一个块,不拆;二是在检索后增加一步“同章节扩展”——命中某块时,把它前后相邻的块也带上一起送进上下文。改完之后,这类对比型问题的完整度提升非常明显,代价是上下文变长、成本略升,但完全值得。
5. 踩坑实录与常见问题速查
5.1 解析阶段的坑
坑一:加密 PDF 静默失败。有些文档带权限密码,能打开但提取文本返回空。我的处理是先检查提取结果是否全空,如果整本都是空字符串,直接报错提示用户,而不是继续往下跑生成一个空索引。
坑二:字体缺字导致乱码。某些中文 PDF 用了非常规字形,提取出来是方框或者问号。这种情况很难在代码层面完全解决,我的策略是统计乱码字符占比,超过 5% 就警告用户这份文档解析质量不佳,问答结果仅供参考。
坑三:水印被当成正文。半透明水印往往和正文重叠,提取出来会插在句子中间。这个基本没法自动分离,只能靠用户上传清晰的版本。我在产品上做了一件事:把解析出来的第一段原文展示给用户确认,让他自己判断解析质量,比事后解释省事得多。
5.2 检索阶段的坑
坑四:问题太短导致语义漂移。用户只输入“违约金”三个字,向量召回的是一堆带“违约金”字样的块,但可能不是他想要的那个条款。这时候关键词召回反而更稳。所以短查询我一般会提高 BM25 那一路的权重,长查询才让向量主导。
坑五:同一个概念多种说法。文档里写“服务费用”,用户问“多少钱”。纯向量能处理这种同义关系,但如果文档里用的是行业术语、用户用的是口语,向量模型可能拉不开差距。我的做法是在建索引时给每个块补一条“关键词摘要”——用规则从块中抽取高频名词短语,一起参与检索。这招成本低,对专业文档效果不错。
坑六:跨块推理问题。问“第三章提到的三个风险点分别对应哪条应对措施”,答案分散在三个块里。纯 top-k 检索很难保证三块都被召回。解决办法是把 k 值临时调大(比如 10 到 15),并在提示词里明确要求“逐一列举所有相关条目”。
5.3 生成阶段的坑
坑七:模型拿常识补文档没写的部分。这是最危险的一类。我的应对分两层:提示词里禁止补充,同时在后处理里检查答案中的每个数字和关键名词是否能在上下文中找到,找不到就标记为“待核实”。完全自动拦截会误伤,但标记出来让用户自己判断,是可接受的折中。
坑八:回答过长导致重点被稀释。长上下文模型往往倾向于把所有相关内容都复述一遍。限制回答字数、要求分点作答,实际效果好于让模型自由发挥。我给的要求通常是 300 字以内。
坑九:多语言混排。中文文档里夹着英文术语,如果向量模型对英文支持一般,这些术语会被编码成噪声。解决办法是解析时保留术语原文,检索时不翻译,让 BM25 那一路去兜底精确匹配。
提示:所有涉及金额、日期、编号的答案,都要在页面上给出原文位置让用户一键核对。这类信息一旦出错,信任损失远大于便利带来的收益。
5.4 成本与性能的实际考量
首字延迟是体验的分水岭。用户问完问题,如果三秒内没有反应,就会觉得卡。我的做法是先把检索结果以“找到这几段相关内容”的形式立刻返回,再把生成的答案流式吐出来。这样用户在前一秒就有反馈,感知上的等待时间大幅缩短。
索引要能复用。同一份文档如果重复问答,不要每次重跑解析和 embedding。我给每个文档算一个内容哈希,哈希不变就直接加载已有索引。这个改动让二次访问的响应时间从几十秒降到几百毫秒。
并发检索的内存问题。FAISS 索引加载到内存后是只读共享的,多线程查询没问题;但重排模型如果每个请求都加载一份,内存会爆。要预加载成单例,请求时排队调用。这个坑我在压测时才发现,本地单人用根本暴露不出来。
增量更新的坑。文档改了一版怎么办?全量重建最省心,但耗时。我的折中方案是按页做哈希,只重新解析和重新向量化变化的页,其余复用。如果改动集中在少数几页,这个优化能省掉八成时间。
6. 我个人在这套东西上的几点体会
做了几版之后,我最大的感受是:这类工具的效果上限,八成由解析质量决定,剩下的才是模型和提示词。我见过太多人一上来就换更大的模型、调更复杂的提示词,结果文档里的表格是错位的、双栏是串行的,喂给再好的模型也是垃圾进垃圾出。所以如果你打算自己搭一套,我建议把时间分配成三份:一半花在解析和清洗上,三成花在切分和检索上,剩下的才给生成。
还有一个小技巧值得试:把用户的问题先做一次轻量的“查询改写”,把口语化的问法转成更接近文档表述的形式,再拿去检索。比如“这个怎么退”改写成“退货流程与条件”。这一步不需要多复杂的模型,用规则加模板也能做出七八成效果,但对召回率的提升立竿见影。我实测下来,改写之后检索命中的稳定性明显提高,代价只是多一次很短的模型调用。
最后一点,别指望一次性做到零错误。文档问答天然存在边界:文档本身写得含糊、表格结构损坏、问题本身有歧义,这些都会让答案不完美。把“给出引用、让人能核对”这条路走通,比追求一个永远正确的黑盒,要靠谱得多。