8年Java开发者AI转型第二周:RAG系统深入 + 向量数据库实战(Day 8-10)
系列 :8年Java开发者AI转型第二周
上一篇 :Day 5-7:第一个RAG Demo(端到端跑通)
本期内容:多格式文档解析(PDF/Word/Excel/PPT)、LangChain Document Loaders 全家桶、文本分割策略对比、元数据管理
一、背景
上一篇我们搭建了一个能工作的 RAG 文档问答系统,但它有一个致命缺陷:只能读取 .md 文本文件。
现实世界中,企业文档是什么格式的?
css
PDF 技术手册
Word 合同文档
Excel 财务报表
PPT 产品介绍
HTML 网页文章
如果你的 RAG 系统只能读 Markdown,那就等于图书馆里只收杂志,书一概不收。
本期目标:构建一个多格式文档解析 Pipeline,让你的 RAG 系统能消化各种文件格式。
二、环境搭建
2.1 安装依赖
bash
# 核心文档解析库
pip install pypdf pymupdf python-docx openpyxl python-pptx
# LangChain 文档加载器
pip install langchain-community
# 高级文档解析(支持扫描件、复杂排版)
pip install unstructured
# 其他依赖
pip install networkx
pip install msoffcrypto-tool
Java 类比 :这就像在 Maven 里加 dependency------以前只用
TextLoader(相当于只加了commons-io),现在要加一堆新的解析器。
2.2 准备测试文档
准备几个不同格式的文档放在 doc/ 目录下:
kotlin
doc/
├── kkfile.md ← 上一篇用的 Markdown 文档
├── kkfile.pdf ← PDF 版技术文档
├── sample.docx ← Word 文档
├── data.xlsx ← Excel 表格
└── intro.pptx ← PPT 演示文稿
三、LangChain Document Loaders 全家桶
3.1 为什么需要统一的解析器
每种文档格式都有自己的解析方式:
| 格式 | 解析难度 | 常见问题 |
|---|---|---|
.md |
简单 | 几乎无问题 |
.pdf |
中等 | 表格丢失、图片无法提取、编码问题 |
.docx |
简单 | 样式信息丢失 |
.xlsx |
中等 | 公式不解析、合并单元格错位 |
.pptx |
困难 | 动画/备注丢失、布局混乱 |
LangChain 为每种格式提供了专用的 DocumentLoader,你只需要统一接口,不用关心底层怎么解析。
3.2 统一文档解析器(核心代码)
python
# multi_format_loader.py
import os
from pathlib import Path
from datetime import datetime
from langchain_community.document_loaders import (
PyPDFLoader,
Docx2txtLoader,
UnstructuredExcelLoader,
UnstructuredPowerPointLoader,
TextLoader,
)
class MultiFormatDocumentLoader:
"""
统一文档解析器:支持 PDF/Word/Excel/PPT/Markdown
设计思路:
- 类似 Java 的策略模式(Strategy Pattern)
- 不同格式 = 不同策略,通过文件扩展名自动选择
- 统一输出 LangChain 的 Document 对象
"""
# 格式映射表(类似 Java 的 Map)
LOADERS = {
'.pdf': PyPDFLoader,
'.docx': Docx2txtLoader,
'.xlsx': UnstructuredExcelLoader,
'.pptx': UnstructuredPowerPointLoader,
'.md': TextLoader,
'.txt': TextLoader,
'.csv': UnstructuredExcelLoader,
}
def __init__(self, encoding: str = "utf-8"):
self.encoding = encoding
def load(self, file_path: str) -> list:
"""加载单个文件,返回 Document 列表"""
ext = Path(file_path).suffix.lower()
loader_class = self.LOADERS.get(ext)
if not loader_class:
raise ValueError(f"不支持的文件格式: {ext}")
# 实例化对应的 Loader
if ext in ('.pdf', '.xlsx','.docx','.pptx'):
loader = loader_class(file_path)
else:
loader = loader_class(file_path, encoding=self.encoding)
docs = loader.load()
# 统一添加元数据
for doc in docs:
doc.metadata.update({
"source_file": file_path,
"file_type": ext,
"file_name": Path(file_path).name,
"load_time": datetime.now().isoformat(),
})
return docs
def load_batch(self, folder_path: str) -> list:
"""批量加载文件夹下所有支持的文档"""
all_docs = []
folder = Path(folder_path)
if not folder.exists():
raise FileNotFoundError(f"文件夹不存在: {folder_path}")
for file_path in folder.glob("*"):
if file_path.suffix.lower() in self.LOADERS:
try:
docs = self.load(str(file_path))
all_docs.extend(docs)
print(f"✅ 加载成功: {file_path.name} ({len(docs)} 页)")
except Exception as e:
print(f"❌ 加载失败: {file_path.name} - {e}")
print(f"\n共加载 {len(all_docs)} 个文档块")
return all_docs
# ========== 使用示例 ==========
if __name__ == "__main__":
loader = MultiFormatDocumentLoader()
# 单文件加载
pdf_docs = loader.load("doc/kkfile.pdf")
print(f"PDF 加载了 {len(pdf_docs)} 页")
print(f"第一页内容: {pdf_docs[0].page_content[:200]}...")
print(f"元数据: {pdf_docs[0].metadata}")
# 批量加载
# all_docs = loader.load_batch("doc/")
运行效果:
kotlin
✅ 加载成功: kkfile.pdf (42 页)
✅ 加载成功: sample.docx (1 页)
✅ 加载成功: data.xlsx (3 页)
共加载 46 个文档块
3.3 Document Loaders 对比表
| Loader | 支持格式 | 特点 | 注意事项 |
|---|---|---|---|
TextLoader |
.txt/.md/.csv | 最简单,纯文本 | 不支持二进制格式 |
PyPDFLoader |
基于 PyMuPDF,速度快 | 扫描件 PDF 无法提取文字 | |
Docx2txtLoader |
.docx | 基于 docx2txt,稳定 | 不支持老版 .doc |
UnstructuredExcelLoader |
.xlsx/.csv | 基于 unstructured | 需安装 unstructured |
UnstructuredPowerPointLoader |
.pptx | 基于 unstructured | 同上 |
UnstructuredMarkdownLoader |
.md | 保留标题层级 | 比 TextLoader 更智能 |
四、文本分割策略对比
4.1 为什么要分割
即使用 PyPDFLoader 读出了 PDF 全文,也不能整本扔给 Embedding:
- Embedding 模型有长度限制:大多数模型只能处理 512~8192 token
- 检索精度:一大段文字里混入无关信息,会降低向量相似度
- Token 成本:喂给 LLM 的上下文越长,费用越高
4.2 三种分割策略
策略 1:RecursiveCharacterTextSplitter(递归字符分割)
最常用,按段落/句子/单词逐级切分:
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块最多 500 字符
chunk_overlap=50, # 相邻块重叠 50 字符
separators=[
"\n\n", # 优先按段落分割
"\n", # 其次按行分割
" ", # 然后按词分割
"" # 最后强制截断
]
)
chunks = splitter.split_documents(docs)
Java 类比 :类似
String.split("\\n+"),但是多级分割------先按段落分,不够再按行分,再不够按词分。
策略 2:MarkdownHeaderTextSplitter(Markdown 标题分割)
专门针对 Markdown,保留标题层级作为元数据:
python
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "一级标题"),
("##", "二级标题"),
("###", "三级标题"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(docs[0].page_content)
# 每个 chunk 的 metadata 会携带标题信息
for chunk in chunks[:3]:
print(f"标题: {chunk.metadata}")
print(f"内容: {chunk.page_content[:100]}...")
输出示例:
css
标题: {'一级标题': '一、背景', '二级标题': ''}
内容: 上一篇我们搭建了一个能工作的 RAG 文档问答系统...
标题: {'一级标题': '一、背景', '二级标题': '2.1 安装依赖'}
内容: pip install pymupdf python-docx openpyxl...
关键价值 :检索时可以根据标题过滤------用户问"安装相关",直接命中
二级标题包含"安装"的 chunk。
策略 3:SemanticChunker(语义分割,LangChain 新特性)
按语义断句,不在句子中间切断:
python
from langchain_text_splitters import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
splitter = SemanticChunker(embeddings, breakpoint_threshold_type="percentile")
chunks = splitter.split_documents(docs)
原理:用 Embedding 计算相邻句子的相似度,相似度突降的地方就是语义边界,在这里切分。
4.3 分割策略对比实验
不同策略对检索效果的影响有多大?做个实验:
python
# chunk_strategy_compare.py
from langchain_text_splitters import (
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter,
)
def compare_strategies(docs):
strategies = {
"Recursive (500/50)": RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50
),
"Recursive (1000/200)": RecursiveCharacterTextSplitter(
chunk_size=1000, chunk_overlap=200
),
"Markdown Header": MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "title")]
),
}
for name, splitter in strategies.items():
chunks = splitter.split_documents(docs)
avg_len = sum(len(c.page_content) for c in chunks) / len(chunks)
print(f"{name:25s} | 块数: {len(chunks):4d} | 平均长度: {avg_len:.0f}")
# 输出:
# Recursive (500/50) | 块数: 42 | 平均长度: 450
# Recursive (1000/200) | 块数: 23 | 平均长度: 890
# Markdown Header | 块数: 15 | 平均长度: 1200
4.4 经验法则
| 文档类型 | 推荐策略 | chunk_size | chunk_overlap |
|---|---|---|---|
| Markdown | MarkdownHeaderTextSplitter |
自动 | 0 |
| PDF/Word | RecursiveCharacterTextSplitter |
500~800 | 50~100 |
| 代码文档 | RecursiveCharacterTextSplitter |
300~500 | 30~50 |
| 学术论文 | SemanticChunker |
自动 | 0 |
| 通用场景 | RecursiveCharacterTextSplitter |
500~1000 | 50~200 |
五、元数据(Metadata)管理
5.1 为什么元数据很重要
想象一下:你的知识库里有 1000 份文档,用户问"帮我查一下 2024 年的合同"。
如果没有元数据,LLM 要遍历所有文档才能判断。有了元数据,可以直接过滤:
makefile
metadata = {
"source_file": "contracts/2024-001.pdf",
"file_type": ".pdf",
"year": 2024,
"doc_type": "contract",
"page": 3
}
5.2 在解析时注入元数据
python
from langchain_community.document_loaders import PyPDFLoader
from pathlib import Path
loader = PyPDFLoader("doc/kkfile.pdf")
docs = loader.load()
# 为每个 chunk 添加元数据
for doc in docs:
doc.metadata.update({
"source_file": "kkfile.pdf",
"file_type": ".pdf",
"doc_category": "technical_document",
"version": "5.0.0",
})
5.3 检索时按元数据过滤
python
# 只检索 PDF 类型的前 3 条
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 3,
"where": {"file_type": ".pdf"} # 元数据过滤
}
)
# 多条件过滤
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 5,
"where": {
"$and": [
{"file_type": ".pdf"},
{"doc_category": "technical_document"}
]
}
}
)
Java 类比 :
where就是 SQL 的WHERE子句,$and就是AND运算符。
5.4 元数据管理最佳实践
| 原则 | 说明 |
|---|---|
| 来源可追溯 | 每个 chunk 记录 source_file 和 page |
| 分类标签 | 按文档类型打标签(technical/marketing/hr) |
| 时间戳 | 记录 load_time 和 update_time |
| 不要过度 | 元数据太多会增加向量库存储,够用就好 |
六、实战:升级 RAG 系统支持多格式文档
把前面所有知识整合,升级上一篇的 RAG Demo:
6.1 完整代码
python
# rag_multiformat.py - 支持多格式的 RAG 系统
import os
import json
from pathlib import Path
from datetime import datetime
from langchain_community.document_loaders import (
PyPDFLoader,
Docx2txtLoader,
TextLoader,
)
from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
from BigModelEmbedding import BigModelEmbeddingFunc
from langchain_community.vectorstores import Chroma
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
class MultiFormatDocumentLoader:
"""统一文档解析器"""
LOADERS = {
'.pdf': PyPDFLoader,
'.docx': Docx2txtLoader,
'.md': TextLoader,
'.txt': TextLoader,
}
def __init__(self, encoding: str = "utf-8"):
self.encoding = encoding
def load(self, file_path: str) -> list:
ext = Path(file_path).suffix.lower()
loader_class = self.LOADERS.get(ext)
if not loader_class:
raise ValueError(f"不支持的文件格式: {ext}")
if ext in ('.pdf',):
loader = loader_class(file_path)
else:
loader = loader_class(file_path, encoding=self.encoding)
docs = loader.load()
for doc in docs:
doc.metadata.update({
"source_file": file_path,
"file_type": ext,
"file_name": Path(file_path).name,
"load_time": datetime.now().isoformat(),
})
return docs
if __name__ == '__main__':
# 加载配置
with open("config_bigmodel.json", "r", encoding="utf-8") as f:
config = json.load(f)
DEEPSEEK_KEY = config["deepseek"]["api_key"]
EMBEDDING_KEY = config["embedding"]["api_key"]
DEEPSEEK_URL = config["deepseek"]["base_url"]
CHROMA_DB_PATH = config["chroma"]["persist_directory"]
# 1. 加载多格式文档
print("========== 1. 加载文档 ==========")
loader = MultiFormatDocumentLoader()
# 支持多种格式
doc_files = [
"doc/kkfile.md", # Markdown
"doc/kkfile.pdf", # PDF
"doc/sample.docx", # Word
]
all_docs = []
for doc_file in doc_files:
if os.path.exists(doc_file):
docs = loader.load(doc_file)
all_docs.extend(docs)
print(f" ✅ {doc_file}: {len(docs)} 页")
else:
print(f" ⚠️ 文件不存在: {doc_file}")
print(f" 总计: {len(all_docs)} 个文档块")
# 2. 文本分割(根据格式选择策略)
print("\n========== 2. 文本分割 ==========")
# Markdown 文件用标题分割器,其他用递归分割
markdown_chunks = []
other_chunks = []
for doc in all_docs:
if doc.metadata.get("file_type") == ".md":
# Markdown 专用分割:保留标题层级
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "title")]
)
markdown_chunks.extend(header_splitter.split_text(doc.page_content))
else:
other_chunks.append(doc)
# 非 Markdown 文档用递归分割
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50
)
other_split = recursive_splitter.split_documents(other_chunks)
all_chunks = markdown_chunks + other_split
print(f" Markdown 分割: {len(markdown_chunks)} 块")
print(f" 其他格式分割: {len(other_split)} 块")
print(f" 总计: {len(all_chunks)} 个分块")
# 3. 向量化 + 存储
print("\n========== 3. 向量化 + 存储 ==========")
embeddings = BigModelEmbeddingFunc(api_key=EMBEDDING_KEY)
if os.path.exists(CHROMA_DB_PATH):
vectorstore = Chroma(
persist_directory=CHROMA_DB_PATH,
embedding_function=embeddings
)
print(f" 加载已有向量库,共 {vectorstore._collection.count()} 条记录")
else:
vectorstore = Chroma.from_documents(
all_chunks, embeddings, persist_directory=CHROMA_DB_PATH
)
vectorstore.persist()
print(f" 新建向量库,存入 {len(all_chunks)} 个分块")
# 4. 构建 RAG 管道
print("\n========== 4. 构建 RAG 管道 ==========")
retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
prompt = PromptTemplate.from_template("""请严格依据下面参考资料回答用户问题,给出回答的依据文件,不要编造内容:
参考资料:{context}
用户问题:{input}
""")
llm = ChatOpenAI(
model="deepseek-v4-pro",
temperature=0.7,
api_key=DEEPSEEK_KEY,
base_url=DEEPSEEK_URL
)
rag_chain = (
{"context": retriever, "input": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# 5. 提问测试
print("\n========== 5. 问答测试 ==========")
questions = [
"kkFileView支持哪些文档格式?",
"kkFileView的官网地址是什么?",
"v5.0.0版本有什么新功能?",
]
for question in questions:
print(f"\n问题: {question}")
result = rag_chain.invoke(question)
print(f"回答: {result[:200]}...")
6.2 运行效果
diff
========== 1. 加载文档 ==========
✅ doc/kkfile.md: 1 页
✅ doc/kkfile.pdf: 42 页
⚠️ 文件不存在: doc/sample.docx
总计: 43 个文档块
========== 2. 文本分割 ==========
Markdown 分割: 15 块
其他格式分割: 28 块
总计: 43 个分块
========== 3. 向量化 + 存储 ==========
新建向量库,存入 43 个分块
========== 4. 构建 RAG 管道 ==========
========== 5. 问答测试 ==========
问题: kkFileView支持哪些文档格式?
回答: kkFileView支持以下文档格式预览:
1. Office办公文档:doc, docx, xls, xlsx...
2. 国产WPS文档:wps, dps, et...
3. PDF/RTF文档...
(共支持23种以上格式)
问题: kkFileView的官网地址是什么?
回答: kkFileView的官网地址是 https://kkview.cn
七、总结
7.1 本期学到的核心知识
| 知识点 | 核心要点 |
|---|---|
| 多格式解析 | LangChain 为每种格式提供专用 Loader,统一接口 |
| 分割策略 | Recursive(通用)、MarkdownHeader(Markdown 专用)、Semantic(语义断句) |
| 元数据管理 | 来源可追溯、分类标签、检索时按元数据过滤 |
| 策略选择 | Markdown 用标题分割,PDF/Word 用递归分割,chunk_size 500~1000 |
7.2 知识地图
sql
Day 1-2: Python 速学 + 环境搭建
Day 3-5: LLM API 调用 + Prompt 工程
Day 5-7: Embedding + ChromaDB + RAG 管道
Day 8-10: 多格式解析 + 分割策略 + 元数据管理 ← 本期
7.3 下一步
下周(第二周后半段 + 第三周)我们将深入:
- Embedding 模型对比:OpenAI vs BGE vs 智谱,哪个检索效果更好?
- 混合检索:向量检索 + BM25 关键词检索
- Rerank 重排序:召回 Top-10 后精排 Top-2
- 查询改写:让用户的问题更适合检索
- 引用溯源:让 AI 回答标注来源
本文代码基于 LangChain 1.x + 智谱 Embedding + DeepSeek LLM,完整项目见:ai-learning