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") # 支持 600 页

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()

# ============================================================
# Step 1: MinerU PDF → Markdown (flash mode, no token needed)
# ============================================================
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")

# ============================================================
# Step 2: LlamaIndex 加载 → 切片 → 向量索引
# ============================================================
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")

# ============================================================
# Step 3: LangChain RetrievalQA 问答链
# ============================================================
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 表格转换、更细粒度的表格级切片、或直接使用支持表格理解的多模态模型。