8年Java开发者AI转型第二周:RAG系统深入 + 向量数据库实战(Day 8-10)

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 .pdf 基于 PyMuPDF,速度快 扫描件 PDF 无法提取文字
Docx2txtLoader .docx 基于 docx2txt,稳定 不支持老版 .doc
UnstructuredExcelLoader .xlsx/.csv 基于 unstructured 需安装 unstructured
UnstructuredPowerPointLoader .pptx 基于 unstructured 同上
UnstructuredMarkdownLoader .md 保留标题层级 比 TextLoader 更智能

四、文本分割策略对比

4.1 为什么要分割

即使用 PyPDFLoader 读出了 PDF 全文,也不能整本扔给 Embedding:

  1. Embedding 模型有长度限制:大多数模型只能处理 512~8192 token
  2. 检索精度:一大段文字里混入无关信息,会降低向量相似度
  3. 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_filepage
分类标签 按文档类型打标签(technical/marketing/hr)
时间戳 记录 load_timeupdate_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

系列文章:Day 1-2 · Day 3-5 · Day 5-7

相关推荐
ximen502_1 小时前
Python 语言知识总结
开发语言·python
流云鹤2 小时前
2.登录模块
python·django
C+-C资深大佬2 小时前
Java 变量:从入门到精通
java·开发语言·python
chouchuang2 小时前
day-027-面向对象-下
开发语言·python
华研前沿标杆游学2 小时前
2026年标杆游学机构选择指南:从资质审核到效果验收全流程本指南
python
衔烛之龙13 小时前
Windows x64 构建 liboqs-java教程
java·windows·python
VIP_CQCRE4 小时前
用 Ace Data Cloud 快速接入 AI 视频生成:HappyHorse Videos API 实战指南
人工智能·python·api·ai视频生成·acedatacloud
阿豪只会阿巴5 小时前
两小时快速入门 FastAPI--第二回
windows·python·fastapi
idingzhi5 小时前
A股量化策略日报(2026年07月12日)
python