1. 项目概述为什么要在没有框架的前提下做文档摘要与问答最近有好几拨朋友在技术群和私信里反复问同一个问题“RAG现在满大街都是LangChain、LlamaIndex为啥还要自己手搭是不是太折腾”——这问题我听了十年每次回答都一样不是为了炫技而是为了可控、可调、可 debug、可嵌入、可交付。“Document Summarization QA in RAG without Frameworks (PyMuPDF ChromaDB)”这个标题表面看是技术选型组合实则是一条明确的工程路线宣言绕过所有高封装抽象层从 PDF 解析、文本切片、向量化、索引构建、检索逻辑到答案生成全程裸写 Python只依赖两个轻量级库——PyMuPDF处理 PDF 的黄金标准和 ChromaDB极简但足够健壮的向量数据库。它解决的不是“能不能跑通”的问题而是“上线后出问题时你敢不敢直接进源码改一行就上线”的问题。比如客户发来一份带复杂表格页眉页脚扫描图嵌入文字的招标文件 PDFLangChain 的PyPDFLoader直接丢掉 40% 内容而 PyMuPDF 可以精准定位每一块文本矩形、过滤水印区域、跳过图像区域再比如线上服务突然响应变慢你查日志发现是向量检索耗时飙升——用 LangChain 就得一层层扒Retriever → BaseRetriever → VectorStoreRetriever的继承链而用原生 ChromaDBcollection.query()的参数、n_results、where过滤条件、include字段全在你眼皮底下连向量相似度阈值similarity_threshold都能实时动态调整。这个方案适合三类人交付型工程师要打包成 Docker 镜像给政企客户部署不能接受“pip install langchain 后还缺 7 个可选依赖”这种不确定性算法侧同学需要在摘要阶段插入自定义的句子重要性打分模型比如基于依存句法树深度加权而不是被load_summarize_chain的固定 pipeline 卡死教学/面试场景想真正讲清楚“RAG 里到底哪一步在做语义匹配Embedding 是怎么和 query 对齐的为什么 chunk size 设 256 比 512 更适合法律条款检索”——所有黑箱必须打开。关键词“PyMuPDF”“ChromaDB”“RAG”“Document Summarization”“QA”不是堆砌而是锚点它们共同指向一个事实——我们不碰任何 LLM 推理框架如 vLLM、Text Generation Inference也不碰任何 orchestration 层如 LangGraph只聚焦在 RAG 最核心的“文档理解→结构化索引→语义检索→上下文注入”四步闭环上。后续你可以把openai.ChatCompletion换成Ollama.run(qwen2:7b)也可以换成本地llama.cpp的 GGUF 模型只要输入是字符串、输出是字符串整个 pipeline 就完全解耦。我去年帮一家医疗器械公司落地临床试验报告智能审阅系统就是用这套裸写方案。他们要求所有 PDF 解析必须通过 ISO 15489-1 文档管理认证审计所有向量索引变更必须留完整操作日志所有 QA 返回结果必须附带原文页码坐标矩形x0,y0,x1,y1。用框架光是解释RecursiveCharacterTextSplitter的chunk_overlap20怎么影响页码映射就开了三次跨部门对齐会。而手写我们直接在 PyMuPDF 的page.get_text(dict)结果里加了page_number和bbox字段往 ChromaDB 的metadatas里一塞前端点击答案就能高亮原文位置——开发周期反而缩短了 3 天。这不是复古是回归本质RAG 的价值不在框架多酷而在你能否在 30 分钟内把客户新给的 200 页 GMP 合规手册变成可精准问答的知识体。2. 整体架构设计与技术选型深挖2.1 为什么坚决不用 LangChain/LlamaIndex三个硬伤直击痛点很多人以为“不用框架重复造轮子”其实恰恰相反——框架才是最大的轮子陷阱。我们拆开看 LangChain 的VectorStoreRetriever在真实生产环境中的三处致命卡点提示以下问题全部来自我们过去 18 个月在 7 个 RAG 项目中踩过的坑已脱敏但保留技术细节。第一文本切片与元数据强耦合断裂。LangChain 的PyPDFLoader默认走pypdf.PdfReader它读取 PDF 时根本不知道“页眉”“页脚”“章节标题”这些语义块。比如一份《GB/T 19001-2016 质量管理体系要求》PDF第 5 页顶部有“5.3 组织的岗位、职责和权限”标题但pypdf把它和下面正文混在一起切片导致检索“岗位职责”时返回的 chunk 包含大段无关的“5.2 方针”内容。而 PyMuPDF 的page.get_text(blocks)能返回每个文本块的(x0,y0,x1,y1, text, block_no)我们据此写了个规则if y0 50: skip跳过页眉区if y1 page.rect.height - 30: skip跳过页脚区if len(text.strip()) 10 and text.isupper(): is_section_header True识别大写短标题。这些逻辑LangChain 的Document对象根本没预留扩展字段。第二向量检索无法细粒度控制相似度计算路径。LangChain 的similarity_search_with_score底层调用 ChromaDB 的query()但你没法干预它的n_results如何影响排序稳定性。实测发现当n_results5时query 向量与 top1 的余弦相似度是 0.72top5 平均值 0.68但设n_results10top1 相似度掉到 0.69——因为 ChromaDB 默认用 ANN近似最近邻加速n_results越大搜索半径越宽精度越低。而裸调 ChromaDB我们可以强制query(..., n_results5, include[distances])拿到原始距离数组后自己写逻辑if distances[0] 0.3: return []相似度阈值熔断或者if distances[4] - distances[0] 0.15: warn(检索结果离散度高建议扩大 chunk size)。这种诊断能力框架层完全屏蔽。第三摘要生成与检索结果无法做上下文感知重排序。LangChain 的load_summarize_chain是单向流水线chunk → summary → merge。但实际中用户问“该设备的校准周期是多少”理想路径是先检出“校准”相关段落再从中抽“周期”实体最后用整篇报告的“范围”“引用标准”等上下文约束答案。框架里你得 hackStuffDocumentsChain的 prompt template而裸写可以直接做两阶段检索第一阶段用 query embedding 找 top10 候选 chunk第二阶段用 “校准周期” 作为 sub-query在这 10 个 chunk 内部做关键词 语义混合检索比如正则匹配\d天|\d个月再对匹配句做 sentence-transformers 编码比对。这种动态策略切换框架的 chain 定义根本无法表达。所以我们的架构图极其简单却极度清晰PDF 文件 → [PyMuPDF 解析] → 原始文本块带 bbox/page_no ↓ [自定义清洗规则] → 结构化 Document 列表text, metadata{page, bbox, section} ↓ [Sentence-BERT 编码] → embeddingsnumpy array ↓ [ChromaDB collection.add()] → 向量索引 元数据存储 ↓ 用户 Query → 编码 → [ChromaDB query()] → 检索结果text metadata ↓ [Prompt 工程注入] → LLM 输入含原文页码、上下文约束指令 ↓ LLM 输出 → 答案 引用溯源全程无中间件、无抽象层、无 magic method。每一行代码对应一个可验证的业务动作。2.2 PyMuPDF为什么它是 PDF 解析不可替代的“手术刀”提到 PDF 解析很多人第一反应是pypdf或pdfplumber但真正在工业级文档处理中活下来的只有 PyMuPDFfitz。原因就一个它不是“读 PDF”而是“解 PDF”。PDF 本质是 PostScript 的衍生由对象流object streams、交叉引用表xref、字体字典font dict等组成。pypdf做的是“渲染后文本提取”即模拟 PDF 阅读器把页面画出来再 OCR 式抓取而 PyMuPDF 直接解析底层对象能拿到每一个字符的精确位置、字体名、字号、颜色、是否加粗。这带来三个不可替代优势① 表格识别零误差。pdfplumber的表格检测靠“找横线纵线”遇到虚线、浅灰色线、合并单元格就崩。PyMuPDF 的page.find_tables()方法基于“文本块空间聚类 边界线拟合”双引擎先用 DBSCAN 聚类所有文本块按 x/y 坐标再在聚类中心画最小包围矩形最后用 Hough 变换检测该矩形内的直线。我们测试过 127 份带复杂合并表头的 FDA 申报表pdfplumber平均漏掉 3.2 个单元格PyMuPDF 是 0。关键代码就三行tables page.find_tables() for tab in tables: df tab.to_pandas() # 直接转 pandas DataFrame # 此时 df.columns 已是准确的表头非空单元格坐标全在 tab._cells 里② 扫描件 PDF 的文本层精准剥离。很多合同 PDF 是扫描件image-based但嵌了 OCR 文本层searchable PDF。pypdf会把图像和文本层混着读导致“甲方__________”这种填空位置被识别成乱码。PyMuPDF 的page.get_text(dict)返回字典其中type字段明确标出img或text我们可以blocks page.get_text(dict)[blocks] text_blocks [b for b in blocks if b[type] 0] # type 0 text img_blocks [b for b in blocks if b[type] 1] # type 1 image # 只处理 text_blocks彻底规避图像干扰③ 页眉页脚/水印的坐标级过滤。这是最体现工程价值的一点。某次给银行做信贷报告分析PDF 每页右下角有“CONFIDENTIAL - DO NOT DISTRIBUTE”水印pypdf会把它和正文一起切片导致所有检索都带上“CONFIDENTIAL”噪声。PyMuPDF 中水印文本块的y1坐标必然接近页面底部比如page.rect.height - 20我们加一行过滤def is_watermark(block): y1 block[bbox][3] page_height page.rect.height return y1 page_height - 30 and len(block[lines][0][spans][0][text]) 15 clean_blocks [b for b in blocks if not is_watermark(b)]这种基于坐标的精准外科手术是任何基于纯文本的解析器做不到的。PyMuPDF 的 API 设计哲学就是“给你原始数据规则你定我们只负责不丢精度。”2.3 ChromaDB轻量但不简陋为什么它比 FAISS/Pinecone 更适配此场景选 ChromaDB 不是因为它“火”而是因为它完美卡在“够用”和“可控”之间。对比主流向量库特性ChromaDBFAISSPinecone安装复杂度pip install chromadb纯 Python需编译 CWindows 上常失败必须联网API Key 认证元数据支持原生支持metadatas[{...}]可where查询仅支持 ID 映射元数据需自己建 hash 表支持但过滤语法不直观如{source: report.pdf}动态 schemaadd()时自动推断字段类型固定向量维度元数据无 schema固定 schema改字段要重建索引调试友好性collection.peek()直接看前 10 条数据需index.reconstruct(n)慢且不准控制台看但无法本地 inspect我们选 ChromaDB 的核心理由有二第一元数据即业务逻辑。在文档 QA 场景中“这份答案来自第几页”“属于哪个章节”“是否为表格内容”这些不是附加信息而是答案可信度的核心指标。ChromaDB 的add()方法允许collection.add( documents[text1, text2], metadatas[ {page: 5, section: 4.2, is_table: False, source: manual_v2.pdf}, {page: 12, section: Table 3, is_table: True, source: manual_v2.pdf} ], ids[doc_001, doc_002] )后续检索时query(..., where{page: {$gte: 3, $lte: 8}})可直接限定页码范围where{is_table: True}可强制只查表格——这种业务规则直译框架层往往要写 custom retriever。第二持久化与内存模式无缝切换。开发时用chromadb.PersistentClient(path./db)数据落盘重启不丢上线时换成chromadb.HttpClient(hostchroma, port8000)对接 Kubernetes 服务。而 FAISS 的write_index()生成二进制文件读取时必须保证index_type一致稍有不慎就Segmentation faultPinecone 则完全无法本地调试网络一断整个 QA 就挂。更关键的是 ChromaDB 的get()方法——它能根据ids精确拉取原始 document 和 metadata这在做摘要时至关重要。比如用户问“总结第 7-10 页内容”我们先collection.get(ids[doc_007, doc_008, ...])拿到原文再送入 LLM而非让 LLM 在海量检索结果中自己找页码。这种“精准供给”是 RAG 准确率的底层保障。3. 核心模块实现与实操细节全解析3.1 PDF 解析与文本结构化从像素坐标到语义块这一步决定整个 RAG 的天花板。很多人以为“把 PDF 转成字符串就行”实则大错特错——未经结构化的文本向量检索效果会衰减 60% 以上。我们用一个真实案例说明某汽车厂商的《电池热管理系统设计规范》PDF共 83 页含 27 个表格、14 处流程图、大量页眉“EV-BMS-2024-Rev3”。用pypdf提取得到 12 万字纯文本但“冷却液流量阈值”这个关键参数分散在 3 个不同表格和 2 段正文里且表格中该参数列名为“Flow Rate (L/min)”正文中叫“minimum coolant flow”。向量化后query “冷却液流量阈值” 的 embedding 和任意一个 chunk 的相似度都不超过 0.55根本无法召回。而用 PyMuPDF 的结构化解析我们实现了三级清洗第一级物理块提取Physical Block Extraction目标拿到每个文本块的绝对坐标、字体、大小剔除图像/水印。import fitz def extract_physical_blocks(pdf_path, page_rangeNone): doc fitz.open(pdf_path) all_blocks [] for page_num in (page_range or range(doc.page_count)): page doc[page_num] # 获取页面所有文本块含坐标 blocks page.get_text(dict)[blocks] for b in blocks: if b[type] ! 0: # 跳过图像/曲线 continue for line in b.get(lines, []): for span in line.get(spans, []): text span[text].strip() if not text or len(text) 2: continue # 坐标(x0,y0)左下(x1,y1)右上 x0, y0, x1, y1 span[bbox] font_size span[size] is_bold bold in span[font].lower() # 水印过滤底部长文本小字号 if y1 page.rect.height - 25 and len(text) 20 and font_size 8: continue all_blocks.append({ text: text, page: page_num 1, bbox: (x0, y0, x1, y1), font_size: font_size, is_bold: is_bold, font_name: span[font] }) doc.close() return all_blocks第二级语义块聚合Semantic Block Aggregation目标把同一段落的多个文本块合并识别标题/正文/列表项。from collections import defaultdict def aggregate_semantic_blocks(blocks): # 按页码分组 pages defaultdict(list) for b in blocks: pages[b[page]].append(b) semantic_docs [] for page_num, page_blocks in pages.items(): # 按 y 坐标排序从上到下 page_blocks.sort(keylambda x: x[bbox][1]) # 合并垂直距离 15px 的块认为是同一段落 current_para [] for b in page_blocks: if not current_para: current_para.append(b) else: prev_y1 current_para[-1][bbox][3] curr_y0 b[bbox][1] if curr_y0 - prev_y1 15: # 行间距小合并 current_para.append(b) else: # 新段落 # 合并当前段落文本 para_text .join([x[text] for x in current_para]) # 判断是否为标题字号 14 且加粗且长度 50 is_title (current_para[0][font_size] 14 and current_para[0][is_bold] and len(para_text) 50) semantic_docs.append({ text: para_text, page: page_num, is_title: is_title, level: h1 if is_title and 第 in para_text else p }) current_para [b] # 处理最后一批 if current_para: para_text .join([x[text] for x in current_para]) is_title (current_para[0][font_size] 14 and current_para[0][is_bold] and len(para_text) 50) semantic_docs.append({ text: para_text, page: page_num, is_title: is_title, level: h1 if is_title and 第 in para_text else p }) return semantic_docs第三级智能切片Smart Chunking目标chunk size 不是固定数字而是按语义边界切。def smart_chunk(documents, max_chunk_len300, overlap50): chunks [] for doc in documents: text doc[text] # 如果是标题单独成 chunk if doc[is_title]: chunks.append({ text: text, metadata: {**doc, chunk_type: title} }) continue # 按标点切分句子 import re sentences re.split(r(?[。])\s, text) current_chunk for sent in sentences: if len(current_chunk) len(sent) max_chunk_len: current_chunk sent else: if current_chunk: chunks.append({ text: current_chunk.strip(), metadata: {**doc, chunk_type: content} }) current_chunk sent # 处理最后一块 if current_chunk.strip(): chunks.append({ text: current_chunk.strip(), metadata: {**doc, chunk_type: content} }) return chunks # 使用示例 raw_blocks extract_physical_blocks(spec.pdf) semantic_docs aggregate_semantic_blocks(raw_blocks) chunks smart_chunk(semantic_docs, max_chunk_len280, overlap40)这个流程产出的chunks列表每个元素都带metadata{page: 7, is_title: False, chunk_type: content, source: spec.pdf}。后续存入 ChromaDB 时这些 metadata 全部可用这才是真正“可追溯”的 RAG。3.2 向量化与索引构建Embedding 模型选型与性能实测向量化不是“选个模型跑一下”就完事。我们实测了 5 款中文 Embedding 模型在文档 QA 场景下的表现结论颠覆常识模型参数量单文档编码耗时ms“冷却液流量阈值”检索 top1 相似度“如何校准温度传感器”检索召回率5text2vec-large-chinese345M12800.6260%bge-m31.2B21500.7182%m3e-base110M4200.6875%multilingual-e5-large560M18900.5955%bge-reranker-base重排340M850rerank only0.83rerank 后94%rerank 后关键发现单纯追求 embedding 维度高不如用小模型 重排Rerank策略。原因在于文档 QA 的 query 往往很短如“校准周期”而文档 chunk 很长平均 280 字直接算余弦相似度短 query 的向量会被长文档的语义稀释。bge-reranker-base 的设计就是专治这个它接收(query, document)pair输出一个 0~1 的相关性分数不依赖向量空间而是 cross-encoder 架构。我们的最终方案是双阶段粗检Retrieve用m3e-base快、省内存从 ChromaDB 检出 top50精排Rerank用bge-reranker-base对这 50 个(query, chunk)打分取 top5。代码实现from sentence_transformers import SentenceTransformer from transformers import AutoModelForSequenceClassification, AutoTokenizer import torch # 初始化 embedder SentenceTransformer(moka-ai/m3e-base) reranker AutoModelForSequenceClassification.from_pretrained(BAAI/bge-reranker-base) tokenizer AutoTokenizer.from_pretrained(BAAI/bge-reranker-base) def hybrid_retrieve(query, collection, top_k50): # Step 1: 粗检 query_emb embedder.encode([query])[0] results collection.query( query_embeddings[query_emb.tolist()], n_resultstop_k, include[documents, metadatas, distances] ) # Step 2: 精排 pairs [(query, doc) for doc in results[documents][0]] inputs tokenizer(pairs, paddingTrue, truncationTrue, return_tensorspt, max_length512) with torch.no_grad(): scores reranker(**inputs, return_dictTrue).logits.view(-1, ).float() # 按 score 降序排列 ranked_indices torch.argsort(scores, descendingTrue).tolist() final_docs [results[documents][0][i] for i in ranked_indices[:5]] final_metas [results[metadatas][0][i] for i in ranked_indices[:5]] return final_docs, final_metas # 使用 docs, metas hybrid_retrieve(校准周期, collection)这个方案在 16G 显存的 RTX 4090 上端到端耗时 320ms含 IO比单用bge-m3快 3.7 倍且准确率更高。工程的本质就是用合理的组合避开单一技术的短板。3.3 摘要生成与问答 Prompt 工程拒绝通用模板拥抱场景定制很多人把 RAG 的 QA 当成“把 chunk 拼进 prompt 就行”结果 LLM 要么胡说要么答非所问。根本原因是没有针对文档类型设计 prompt。我们把文档分为三类每类配专属 prompt① 法规/标准类文档如 GB/T、ISO特点条款编号严格“5.2.1”答案必须精确到条款号禁止推测。Prompt 模板你是一名专业合规顾问正在审阅一份技术标准文档。请严格依据提供的上下文作答禁止任何外部知识或推测。 【上下文开始】 {context} 【上下文结束】 问题{question} 要求 - 答案必须包含且仅包含原文中的条款编号如“5.2.1”和对应内容 - 若上下文未明确提及回答“未找到直接依据” - 禁止使用“可能”、“通常”、“一般”等模糊词汇。② 实验报告/检测数据类特点含大量数值、单位、条件“25℃±2℃湿度 45%~55%”答案需保留精度。Prompt 模板你是一名实验室数据分析师。请从提供的检测报告片段中提取问题要求的精确数值及单位。 【上下文开始】 {context} 【上下文结束】 问题{question} 要求 - 数值必须与原文完全一致包括小数位数、单位符号 - 若存在多个数值按原文出现顺序列出 - 若上下文未提供数值回答“数据缺失”。③ 操作手册/流程指南类特点步骤性强“第一步...第二步...”答案需保持动作顺序。Prompt 模板你是一名资深设备工程师正在指导现场人员执行操作。请严格按提供的手册步骤作答。 【上下文开始】 {context} 【上下文结束】 问题{question} 要求 - 答案必须是完整的操作步骤序列格式为“1. ...2. ...3. ...” - 每步必须包含主语如“操作员”、“系统”和动词如“按下”、“确认” - 若步骤跨页需注明“见第 X 页”。我们甚至为每个文档 source 维护一个 prompt profile JSON{ source: battery_spec_v2.pdf, doc_type: standard, prompt_template: standard_template_v3, required_fields: [clause_number, exact_text], confidence_threshold: 0.85 }这样hybrid_retrieve返回结果后系统自动加载对应 prompt确保“一把钥匙开一把锁”。3.4 ChromaDB 索引优化实战从 100ms 到 12ms 的查询提速默认 ChromaDB 配置在百万级向量时query()耗时会从 20ms 涨到 100ms。我们通过四项实操优化将 P95 延迟压到 12ms① 向量维度裁剪Dimensionality Reductionm3e-base输出 768 维但文档 QA 中高频语义如“校准”“周期”“温度”集中在前 256 维。我们用 PCA 降维from sklearn.decomposition import PCA import numpy as np # 训练集随机采样 1 万 chunk 的 embedding emb_matrix np.array(all_embeddings) # shape: (10000, 768) pca PCA(n_components256) pca.fit(emb_matrix) # 保存 pca 模型 joblib.dump(pca, pca_256.pkl) # 编码时 emb embedder.encode([text])[0] emb_reduced pca.transform([emb])[0] # shape: (256,)实测降维后相似度计算耗时下降 40%top5 准确率仅降 0.8%从 94.2% → 93.4%完全可接受。② HNSW 参数调优ChromaDB 底层用 hnswlib关键参数ef_construction: 构建时邻居数默认 200 → 调至 100建索引快 30%查询略慢但可接受M: 每个节点的最大连接数默认 16 → 调至 32提升高并发查询稳定性ef: 查询时探索邻居数默认 10 → 动态设置ef min(50, int(1.5 * n_results))# 创建 collection 时指定 client chromadb.PersistentClient(path./db) collection client.create_collection( namedocs, metadata{ hnsw:construction_ef: 100, hnsw:M: 32, hnsw:search_ef: 50 } )③ 元数据预过滤Metadata Pre-filtering避免“先查向量再过滤”的低效。比如用户限定“只查第 5-10 页”我们直接# 构建时为每页创建独立 collection分片 for page_num in range(1, 84): page_coll client.get_or_create_collection( namefdocs_page_{page_num}, metadata{hnsw:search_ef: 20} # 单页数据少ef 可更低 ) # 只 add 该页的 chunks page_coll.add(...)查询时若用户指定页码直连对应 collection跳过全局搜索。④ 内存映射Memory Mapping对超大 collection100 万向量启用 mmap# 启动 ChromaDB 时 chroma_server subprocess.Popen([ chroma, run, --path, ./db, --host, 0.0.0.0, --port, 8000, --settings-chroma-db-settings-chroma-db-settings-anonymized-telemetry-enabled, false, --settings-chroma-db-settings-chroma-db-settings-mmap-enabled, true # 关键 ])mmap 让向量数据直接从磁盘映射到内存地址空间避免全量加载内存占用降低 65%。这四项优化后我们 230 万向量的医药法规库P95 查询延迟稳定在 11.8msQPS 达到 1850单节点。4. 常见问题与排查技巧实录4.1 PDF 解析类问题为什么我的 PDF 总是提取不全这是最高频问题。我们整理了 7 类典型 PDF 及其解法全部来自真实客户文档PDF 类型现象根本原因PyMuPDF 解决方案扫描件无文本层get_text()返回空字符串PDF 是纯图像无 embedded text用page.get_pixmap(dpi150)提取图像调用pytesseractOCR或告知客户“需提供 searchable PDF”加密 PDF