MinerU + LlamaIndex + LangChain:构建 PDF 文档问答系统的踩坑记录
项目简介
本文记录如何使用 MinerU(PDF 转 Markdown)、LlamaIndex(文档索引与检索)和 LangChain(问答链编排)搭建一个小型文档问答系统。完整流程如下:
1 2 3 4 5 6
| 本地 PDF (pcm5102a.pdf, 44页) → MinerU Flash API → Markdown 文本 (56k 字符) → LlamaIndex MarkdownNodeParser → 37 个语义节点 → HuggingFaceEmbedding (bge-small-zh-v1.5) → VectorStoreIndex → 自定义 LangChain Retriever → RetrievalQA + DeepSeek V4 → 中文回答
|
测试问题:“这个芯片的工作电压范围是多少”
环境依赖
1 2
| pip install mineru-open-sdk llama-index llama-index-embeddings-huggingface \ sentence-transformers langchain langchain-openai langchain-community
|
踩坑记录
Bug 1: MinerU Flash 模式 20 页限制
现象:调用 client.flash_extract() 解析 44 页的 PDF 时返回错误:
1 2 3
| ExtractResult(state='failed', err_code='-30003', error='file page count exceeds API limit (20 pages), please input page_range to specify the page range')
|
原因:MinerU 的 Flash 模式(免费,无需 token)限制最多 20 页。超过限制直接返回 state='failed',markdown 字段为 None。
解决:添加 page_range 参数截取前 20 页,并检查 result.state:
1 2 3 4
| result = client.flash_extract("file.pdf", page_range="1-20")
if result.state == "failed": raise RuntimeError(f"MinerU 解析失败: {result.error}")
|
注意:如果需要解析完整文档,可以申请 MinerU Precision 模式的免费 token(支持 600 页),然后使用 client = MinerU("your-token") 调用 client.extract()。
Bug 2: openai 包版本冲突
现象:安装 llama-index 后 langchain-openai 无法正常工作。
原因:llama-index 依赖 llama-index-embeddings-openai 和 llama-index-llms-openai,它们会安装最新版 openai 包(2.x)。但 langchain-openai 0.3.28 要求 openai>=1.86.0,<2.0.0,两者不兼容。
解决:安装完所有依赖后,强制降级 openai 到兼容版本:
1
| pip install "openai>=1.86.0,<2.0.0"
|
Bug 3: LlamaIndexRetriever API 不兼容
现象:使用 langchain_community.retrievers.LlamaIndexRetriever 时报错:
1
| AttributeError: 'VectorStoreIndex' object has no attribute 'query'
|
原因:LlamaIndexRetriever 内部调用 index.query(),但新版 LlamaIndex(0.14.x)的 VectorStoreIndex 已移除此方法,改为 index.as_retriever().retrieve() 和 index.as_query_engine().query()。社区集成的更新滞后于 LlamaIndex 的 API 变更。
解决:编写自定义 Retriever 适配器,实现 _get_relevant_documents 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from langchain_core.retrievers import BaseRetriever from langchain_core.documents import Document as LCDocument
class LlamaIndexAdapter(BaseRetriever): """将 LlamaIndex index 适配为 LangChain retriever。"""
index: VectorStoreIndex top_k: int = 4
def _get_relevant_documents(self, query: str) -> list[LCDocument]: nodes = self.index.as_retriever( similarity_top_k=self.top_k ).retrieve(query) return [ LCDocument(page_content=node.text, metadata=node.metadata or {}) for node in nodes ]
|
这样将 LlamaIndex 的 NodeWithScore 转换为 LangChain 的 Document,完全兼容 RetrievalQA 链。
改进建议
1. HTML 表格预处理
MinerU 对 PDF 中的表格输出为原始 HTML(<table> 标签),嵌入模型对 HTML 的语义理解较弱。建议在 LlamaIndex 切片前做转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| from bs4 import BeautifulSoup import re
def html_table_to_markdown(text: str) -> str: """将 HTML 表格转为 Markdown 表格,提升检索质量。""" soup = BeautifulSoup(text, "html.parser") for table in soup.find_all("table"): rows = [] for tr in table.find_all("tr"): cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])] rows.append("| " + " | ".join(cells) + " |") if rows: rows.insert(1, "|" + "|".join(["---"] * len(rows[0].split("|"))) + "|") md_table = "\n".join(rows) table.replace_with(soup.new_string("\n" + md_table + "\n")) return soup.get_text()
|
2. 使用 Precision 模式解析完整文档
Flash 模式虽然方便,但 20 页限制对数据手册类文档不够。建议申请免费 token(https://mineru.net/apiManage/token),改用精度模式:
1 2
| client = MinerU("your-api-token") result = client.extract("file.pdf")
|
3. 缓存 MinerU 解析结果
MinerU API 调用耗时数秒,开发调试时每次都重新解析浪费时间和配额。建议缓存:
1 2 3 4 5 6 7 8 9 10
| import pickle from pathlib import Path
cache_path = Path("parsed_markdown.pkl") if cache_path.exists(): markdown_text = pickle.loads(cache_path.read_bytes()) else: result = client.flash_extract("file.pdf", page_range="1-20") markdown_text = result.markdown cache_path.write_bytes(pickle.dumps(markdown_text))
|
4. 优化切片策略
MarkdownNodeParser 按标题层级切片,对于数据手册这种表格密集型文档,可能把大表格切成孤立的碎片。可以尝试 SentenceSplitter 或 TokenTextSplitter 控制 chunk 大小:
1 2 3 4
| from llama_index.core.node_parser import SentenceSplitter
parser = SentenceSplitter(chunk_size=1024, chunk_overlap=200) nodes = parser.get_nodes_from_documents(documents)
|
完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| import os from dotenv import load_dotenv
load_dotenv()
from mineru import MinerU
client = MinerU() print("正在使用 MinerU 解析 PDF(flash 模式,前 20 页)...") result = client.flash_extract("/Users/jialiang/Downloads/pcm5102a.pdf", page_range="1-20")
if result.state == "failed": raise RuntimeError(f"MinerU 解析失败: {result.error} (err_code={result.err_code})")
markdown_text = result.markdown print(f"=== MinerU 解析完成,Markdown 共 {len(markdown_text)} 字符 ===\n") print(markdown_text[:800]) print("\n...\n")
from llama_index.core import Document, VectorStoreIndex, Settings from llama_index.core.node_parser import MarkdownNodeParser from llama_index.embeddings.huggingface import HuggingFaceEmbedding
Settings.embed_model = HuggingFaceEmbedding( model_name="BAAI/bge-small-zh-v1.5", )
documents = [Document(text=markdown_text)] nodes = MarkdownNodeParser().get_nodes_from_documents(documents) print(f"LlamaIndex: 将 Markdown 拆分为 {len(nodes)} 个节点")
index = VectorStoreIndex(nodes) print("LlamaIndex: 向量索引构建完成\n")
from langchain.chains import RetrievalQA from langchain_core.retrievers import BaseRetriever from langchain_core.documents import Document as LCDocument from langchain_openai import ChatOpenAI
class LlamaIndexAdapter(BaseRetriever): """将 LlamaIndex index 适配为 LangChain retriever。"""
index: VectorStoreIndex top_k: int = 4
def _get_relevant_documents(self, query: str) -> list[LCDocument]: nodes = self.index.as_retriever(similarity_top_k=self.top_k).retrieve(query) return [ LCDocument(page_content=node.text, metadata=node.metadata or {}) for node in nodes ]
retriever = LlamaIndexAdapter(index=index)
llm = ChatOpenAI( model="deepseek-v4-pro", base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"), api_key=os.getenv("DEEPSEEK_API_KEY"), temperature=0.3, )
qa = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=retriever, )
query = "这个芯片的工作电压范围是多少" print(f"问题: {query}") print("正在检索并生成回答...\n")
answer = qa.invoke({"query": query}) print(f"回答: {answer['result']}")
|
总结
三个核心坑点:
| # |
问题 |
根因 |
解决 |
| 1 |
MinerU 解析失败 |
Flash 模式 20 页上限 |
加 page_range="1-20" |
| 2 |
openai 版本冲突 |
llama-index 装上 openai 2.x |
pip install "openai<2.0" |
| 3 |
VectorStoreIndex 无 query |
LangChain 集成滞后于 API 变更 |
自定义 LlamaIndexAdapter |
整套系统的瓶颈在于 PDF 表格的检索质量——MinerU 输出的 HTML 表格对嵌入模型不友好。改进方向:HTML→Markdown 表格转换、更细粒度的表格级切片、或直接使用支持表格理解的多模态模型。