前言:本文系统介绍了 RAG(检索增强生成)技术的核心概念、实现流程与实战经验。首先阐述了 RAG 的定义、价值及其与微调的对比,并梳理了从 Naive RAG 到 Agentic RAG 的演进路径。随后,详细讲解了 LangChain 中文档加载(Document 对象、各类加载器及 DirectoryLoader 批量加载)与文档分块(分块原因、策略、核心参数及中文优化技巧)两大核心环节。最后,通过 loader.py 完整代码解析和实战踩坑经验,提供了从理论到实践的完整指南,帮助读者构建高效、可靠的 RAG 应用。
一、RAG介绍
1.1 什么是RAG?
RAG(检索增强生成)是一种结合检索和生成两种方法的技术。它通过先检索相关的文档,用检索出来的信息对提示词增强,再使用大模型生成答案。
RAG的本质:RAG = 大模型LLM + 外部数据
1.2 为什么需要 RAG?
大语言模型(LLM)虽然强大,但直接使用依旧有三个绕不开的痛点:
时效性 :训练数据有截止日期,在你不使用联网搜索的情况下,大模型是无法询问最新的时事的。
知识覆盖度 :虽然大模型的训练数据集非常庞大,但仍可能无法涵盖所有领域的知识或特定领域的深度信息。
幻觉问题:大模型在某些情况(提问方式不对,模型知识欠缺)下给出的回答很可能是错误的,或者是虚构的甚至是故意欺骗的信息,即一本正经胡说八道。
1.3 RAG vs 微调
下面将以表格的形式,从适用场景、数据成本、更新成本、可解释性、幻觉控制和典型项目这六个方面,将RAG和微调进行对比。
| 对比维度 | RAG | 微调(Fine-tuning) |
|---|---|---|
| 适用场景 | 知识频繁更新、需引用来源、私有文档问答 | 改变模型风格/格式/能力、领域术语内化 |
| 数据成本 | 原文即可,无需标注 | 需要高质量的指令 |
| 更新成本 | 增删文档即可,几乎零成本 | 重新训练,成本高 |
| 可解释性 | 强(可追溯来源文档) | 弱(知识融在权重里) |
| 幻觉控制 | 好(有上下文约束) | 一般 |
| 典型项目 | 课程助手、企业知识库、客服 | 代码生成、特定文体写作 |
1.4 Naive RAG 的三步流水线(索引→检索→生成)
Naive RAG(朴素 RAG)是 RAG 的最基础形态,固定为三步:
[索引阶段] [检索阶段] [生成阶段] 文档 → 切分 → 问题向量化 → 问题 + 上下文 → Embedding → 相似度匹配 → 拼 Prompt → 存入向量库 → 取 Top-K 块 → LLM 生成答案索引阶段(Indexing) :把原始文档切分成小块,每块用 Embedding 模型转成向量,存入向量数据库。这是离线一次性完成的。
检索阶段(Retrieval) :用户提问时,把问题也转成向量,在向量库里找最相似的 K 个文本块。
生成阶段(Generation) :把检索到的文本块作为上下文,和原问题一起塞进 Prompt,让 LLM 基于上下文回答。
本章会重点讲解索引阶段中的文档加载和分块。
1.5 RAG 演进路径
RAG 技术已经演进了三代,了解这个路径有助于你认清本项目的位置:
| 阶段 | 别名 | 标志性组件 |
|---|---|---|
| RAG v0 | Naive RAG | 切分 + 向量 + Top-K + Prompt |
| RAG v1 | Advanced RAG | + 查询改写 / 重排序 / 上下文压缩 / 混合检索 |
| RAG v2 | Modular RAG | + 查询路由 / 图索引 / Agent 编排 / 自我评估 |
| RAG v3 | Agentic RAG | LLM 自主决定何时检索、检索什么、如何验证 |
本项目属于Naive RAG,只是进行了一些轻量优化,即分块器加了中文分割符,以及检索时使用了MMR算法。
二、文档加载
2.1 Document 对象是什么
在 LangChain 的世界里,所有加载器返回的不是字符串,不是字典,而是统一的 Document 对象 。理解它是理解整个加载体系的前提。
class Document: page_content: str # 文档的文本内容 metadata: dict # 文档的元数据(来源、页码等)Document属性详解:
page_content :文档的纯文本内容,后续会被切分、向量化、检索。
metadata :元数据字典,常见字段有:source :文件路径(最有用,RAG 回答时可以引用来源)page :页码(PDF 加载器会自动填充)total_pages :总页数自定义字段(如课程阶段、章节标题)。
为什么要把元数据和内容绑在一起?因为在 RAG 的生成阶段,我们不仅要告诉模型"答案在这段文字里",还要能告诉用户"这段文字来自哪个文件的哪一页"。本项目中也有用到,后续大家就能看到。
2.2 LangChain文档加载器
LangChain 提供了上百种加载器。会详细介绍其中常用的的加载器。
2.2.1 常用加载器速查表
| 数据类型 | 推荐加载器 | 底层依赖 | 适用场景与特点 |
|---|---|---|---|
| Markdown(.md) | UnstructuredMarkdownLoader | unstructured | 通用推荐,能解析标题、列表等结构,比 TextLoader 更智能。 |
| 纯文本 (.txt) | TextLoader | 内置 | 最简单,读取纯文本内容。 注意:必须指定 encoding |
| Word (.docx) | Docx2txtLoader | docx2txt | 提取纯文本,轻量,适合无需保留格式的场景 |
| Word (.docx) | UnstructuredWordDocumentLoader | unstructured | 功能更强大,能识别段落、标题、列表等结构 |
| PDF (.pdf) | PyPDFLoader | pypdf | 最常用, 按页切分 ,自带 page 元数据。 |
| PDF (.pdf) | UnstructuredPDFLoader | unstructured | 适合复杂 PDF,能识别表格和元素。 |
| PPT (.pptx) | UnstructuredPowerPointLoader | unstructured | 解析 PPT,支持按 elements 模式提取标题、正文。 |
| Excel (.xlsx) | UnstructuredExcelLoader | unstructured | 解析 Excel,将每个单元格或表格转换为 Document。 |
| CSV | CSVLoader | 内置 | 将每一行转为一个 Document,适合问答对、数据集。 |
| JSON | JSONLoader | 内置 | 解析 JSON,支持 jq 语法提取特定字段,适合 API 返回数据。 |
| HTML | UnstructuredHTMLLoader | unstructured | 从 HTML 中提取干净文本,去除标签。 |
| 目录批量 | DirectoryLoader | 内置 | 核心工具,自动遍历目录 + 匹配文件 + 调用上述加载器。 |
2.2.2 各加载器详细用法与示例代码
1. 纯文本加载器:TextLoader加载器
最简单的加载器,把整个文件读成一个 Document 。
基本用法:
from langchain_community.document_loaders import TextLoader 基本用法(必须显式指定 encoding,否则 Windows 下中文乱码) loader = TextLoader("笔记.txt", encoding="utf-8") docs = loader.load() print(docs[0].page_content) # 整个文件内容作为一个字符串 print(docs[0].metadata) # {'source': '笔记.txt'}2. Word 加载器:Docx2txtLoader和UnstructuredWordDocumentLoader
Docx2txtLoader(轻量版):依赖 docx2txt 库,提取纯文本,丢失格式。
特点:
- 整个文档一个 Document,不按页/段切分
- 只提取文本,丢失表格、图片、加粗等格式
- 依赖轻量,安装简单: pip install docx2txt
- 适合不需要保留格式的场景
基本用法
from langchain_community.document_loaders import Docx2txtLoader loader = Docx2txtLoader("课程笔记.docx") docs = loader.load() print(docs[0].page_content) # Word 中所有文本拼接成一个字符串 print(docs[0].metadata) # {'source': '课程笔记.docx'}UnstructuredWordDocumentLoader(增强版):功能更强,能识别段落、标题、列表等结构,支持 mode="elements" 。
特点:
- 依赖 unstructured + python-docx
- mode="elements" 能区分标题、正文、列表,metadata 带 category
- 适合:结构复杂的 Word 文档、需要保留层次的场景
基本用法:
from langchain_community.document_loaders import UnstructuredWordDocumentLoader 模式一:合并为一个 Document(默认) loader = UnstructuredWordDocumentLoader("课程笔记.docx") docs = loader.load() 模式二:按元素切分(标题、段落、列表项各自独立) loader = UnstructuredWordDocumentLoader( "课程笔记.docx", mode="elements" # 每个元素一个 Document ) docs = loader.load() docs[0].metadata 会包含 {"category": "Title", ...} 等结构信息3. PDF 加载器:PyPDFLoader和UnstructuredPDFLoader
PyPDFLoader:最常用的 PDF 加载器, 按页切分 ,每页一个 Document。
特点:
- 按页切分 是最大优势,RAG 回答时能精确引用"第几页"
- metadata 自动带 page 和 total_pages ,对来源引用很有用
- 依赖 pypdf ,安装简单
- 缺点:复杂版面(多栏、表格)提取效果一般,页眉页脚会被当正文
基本用法
from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader("Ollama 安装教程.pdf") docs = loader.load() 一页一个 Document print(len(docs)) # 文档页数,如 5 print(docs[0].page_content) # 第 1 页的文本内容 print(docs[0].metadata) { 'source': 'Ollama 安装教程.pdf', 'page': 0, # 页码(从 0 开始) 'total_pages': 5 # 总页数 }UnstructuredPDFLoader:能识别表格、标题、列表等结构,适合复杂 PDF。
特点:
- strategy="hi_res" 高精度模式能识别表格、图片,但需要额外系统依赖
- mode="elements" 按元素切分,区分 Title/Table/NarrativeText 等
- 依赖较重,安装复杂(需 poppler、tesseract 等)
- 适合:学术论文、复杂版面 PDF、含表格的PDF
基本用法:
from langchain_community.document_loaders import UnstructuredPDFLoader 模式一:合并为一个 Document loader = UnstructuredPDFLoader("复杂课件.pdf") docs = loader.load() 模式二:按元素切分(标题、表格、图片各自独立) loader = UnstructuredPDFLoader( "复杂课件.pdf", mode="elements", strategy="hi_res" # 高精度模式,能识别表格 ) docs = loader.load() docs[0].metadata 包含 {"category": "Table", "page_number": 1, ...}4. PPT 加载器:UnstructuredPowerPointLoader
特点:
- 依赖 unstructured + python-pptx
- 可能会把 演讲者备注 也读进来,需确认是否符合预期
- 表格内容会被扁平化为文本,结构信息部分丢失
- mode="elements" 对 RAG 更友好,能按元素类型做差异化处理
基本用法:
from langchain_community.document_loaders import UnstructuredPowerPointLoader 模式一:合并(默认)—— 整个 PPT 合成一个 Document loader = UnstructuredPowerPointLoader("AI大模型初识.pptx") docs = loader.load() print(len(docs)) # 1 print(docs[0].page_content) # 所有幻灯片文本拼接 模式二:按元素切分 —— 标题、正文、表格各自独立 loader = UnstructuredPowerPointLoader( "AI大模型初识.pptx", mode="elements" ) docs = loader.load() print(len(docs)) # 元素数量(通常 = 幻灯片数 × 每片元素数) print(docs[0].page_content) # 第一个元素的内容(通常是标题) print(docs[0].metadata) { 'source': 'AI大模型初识.pptx', 'category': 'Title', # 元素类型:Title/NarrativeText/Table 'page_number': 1, # 幻灯片页码 'filetype': 'application/vnd...presentationml.presentation' }两种模式对比:
- mode="single" (默认),输出时整个 PPT 一个 Document,适用于内容少、结构简单的场景
- mode="elements" ,输出时每个元素一个 Document ,适用于需要精确定位标题/正文/表格的场景
5. Markdown 加载器: UnstructuredMarkdownLoader
Markdown 是 RAG 项目中最常见的格式。虽然可以用 TextLoader 读 .md ,但专用加载器能更好地保留结构。
基本用法:
from langchain_community.document_loaders import UnstructuredMarkdownLoader 基本用法 loader = UnstructuredMarkdownLoader("example.md") docs = loader.load() 进阶:按元素切分 (mode="elements") 这会将标题、段落、列表项分成独立的 Document loader = UnstructuredMarkdownLoader("example.md", mode="elements") docs = loader.load() 此时 docs[0].metadata 会包含 {"category": "Title", ...}6. 结构化数据加载器: CSVLoader & JSONLoader
当知识库是结构化数据(如 FAQ 对、产品列表)时,使用这些加载器能直接将数据转为问答对,极大提升检索效率。
CSVLoader (适合 FAQ / 数据集)
- 特点 :将 CSV 的 每一行 转换为一个 Document 。
- 用法 :指定哪一列作为 page_content 。
基本用法:
from langchain_community.document_loaders import CSVLoader 假设有一个 faq.csv,列名是 "question", "answer" 将 "question" 和 "answer" 拼接成内容 loader = CSVLoader( file_path="faq.csv", csv_args={ "delimiter": ",", "quotechar": '"', }, # 自定义内容格式 page_content_column="question", # 或者自定义处理 # 更好的方式:自定义将多列合并 ) 更标准的写法是使用 pandas 处理后加载,或在 metadata 中保留其他列JSONLoader (适合 API 数据)
- 特点 :解析 JSON 文件,支持提取特定字段。
- 用法 :需要配合 jq 表达式来定义“什么是文档内容”。
基本用法:
from langchain_community.document_loaders import JSONLoader 假设 data.json 结构为 [{"title": "...", "content": "..."}, ...] loader = JSONLoader( file_path="data.json", jq_schema=".[].content", # 提取数组中每个元素的 content 字段作为文本 text_content=True, # metadata 指定 title 字段 metadata_func=lambda record, metadata: {**metadata, "title": record.get("title")} ) docs = loader.load()7. 表格加载器: UnstructuredExcelLoader
from langchain_community.document_loaders import UnstructuredExcelLoader 加载 Excel,默认 mode="elements",每个单元格是一个 element loader = UnstructuredExcelLoader("report.xlsx") docs = loader.load() 或者 mode="single",将整个表合并为一个文本 loader = UnstructuredExcelLoader("report.xlsx", mode="single")8. HTML 加载器: UnstructuredHTMLLoader
从网页下载的 HTML 文件直接喂给 LLM 会充满噪声,HTML 加载器负责清洗。
from langchain_community.document_loaders import UnstructuredHTMLLoader 自动去除 HTML 标签,提取纯文本 loader = UnstructuredHTMLLoader("webpage.html") docs = loader.load()9. 目录批量加载器:DirectoryLoader
这是本项目的 核心加载器 , loader.py 用它一次性加载了全部课程资料。上面所有加载器都可以作为它的 loader_cls 。
完整用法
from langchain_community.document_loaders import DirectoryLoader, TextLoader loader = DirectoryLoader( path="./date/notes/", # 1. 扫描目录 glob="**/*.md", # 2. 文件匹配规则 loader_cls=TextLoader, # 3. 用哪个加载器读 loader_kwargs={ # 4. 传给加载器的参数 "encoding": "utf-8" }, show_progress=True, # 5. 显示进度条(可选) use_multithreading=True, # 6. 多线程加速(可选) silent_errors=True # 7. 出错时跳过而非中断(可选) ) docs = loader.load()参数详解:
| 参数 | 作用 |
| path | 扫描根目录 |
| glob | 文件匹配模式 |
| loader_cls | 用哪个加载器读文件 |
| loader_kwargs | 透传给loader_cls的参数 |
| show_progress | 显示加载进度条 |
| use_multithreading | 多线程并行加载 |
| silent_errors | 出错跳过不中断 |
glob 模式详解:
"*.md" # 只匹配当前目录下的md文件(不递归子目录) "**/*.md" # 递归匹配所有子目录下的md文件(本项目用) "**/*.{md,txt}" # 同时匹配md和txt "**/*.pdf" # 递归匹配所有PDF "**/L/**/*.md" # 只匹配L目录下的md三、文档分块
3.1 为什么要分块?
我们在加载完文档后并不能直接使用,必须要先进行分块,主要有以下三个原因:
原因一:Embedding 模型有长度上限,Embedding 模型是有 token 上限(通常为8192 tokens)。一篇 50 页的 PDF 全文塞进去会直接报错。
原因二:长文本会"稀释"语义,降低检索精度 Embedding 的原理是把文本压缩成一个固定维度的向量。文本越长,向量里承载的语义越"平均化",检索时匹配度下降。比如一篇同时讲"RAG"和"LangChain"的长文,向量化后既不像专门讲 RAG 的,也不像专门讲 LangChain 的,用户问任何一个主题都匹配不精确。
原因三:控制 LLM 上下文成本 检索到的上下文要塞进 Prompt 给 LLM。块太大,一次塞不了几块,还浪费 token;块太小,又缺乏完整语义。分块是平衡检索精度和生成质量的关键杠杆。
一句话总结 :分块策略决定了 RAG 效果的天花板,做得差的话,后面的模型再强也救不回来。
3.2 分块方式总览
常见的分块方式有四种:
| 分块方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 按字符 | 固定字符数切分 | 简单、可控 | 可能切断词语/句子 |
| 按Token | 固定token数切分 | 精确控制模型输入 | 需要tokenizer依赖 |
| 按语义 | 用Embedding计算语义边界 | 语义完整性最好 | 计算成本高、慢 |
| 按结构 | 按 Markdown 标题/代码块/段落 | 保留文档结构 | 依赖文档格式规范 |
本项目用的是 按字符 + 按结构 的混合方式,这也是 LangChain 最推荐的通用方案。
3.3 LangChain中常用的分块器
LangChain 提供了多种分块器,选对分块器是 RAG 调优的第一步:
| 分块器 | 切分策略 | 适用场景 |
|---|---|---|
| CharacterTextSplitter | 按单一分隔符(如 \n\n )切,再按字符数补切 | 简单场景 |
| RecursiveCharacterTextSplitter | 按分隔符优先级递归切分 | 通用首选 |
| TokenTextSplitter | 按 token 数切 | 需精确控制 token |
| MarkdownHeaderTextSplitter | 按 Markdown 标题切,保留层级 | 纯 Markdown 文档 |
| HTMLHeaderTextSplitter | 按 HTML 标签切 | 网页内容 |
| SemanticChunker | 按 Embedding 相似度动态切 | 高质量需求 |
| RecursiveJsonSplitter | 递归切 JSON | 结构化数据 |
选型建议:
- 不确定用什么 → RecursiveCharacterTextSplitter (通用首选)
- 纯 Markdown 文档 → MarkdownHeaderTextSplitter + RecursiveCharacterTextSplitter 组合
- 追求极致质量 → SemanticChunker (慢但语义完整)
- 代码文档 → 自定义分隔符按函数/类切
3.4 三大核心参数详解
本项目的分块代码:
splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=100, separators=["\n#", "\n##", "\n###", "\n\n", "\n", "。", ";", ".", ""] )代码中的三个参数是 RAG 调优的核心,我会逐个进行讲解:
参数一: chunk_size (块大小)
含义 :每个文本块的最大字符数。
取值经验 :
- 太小(如 100-200):语义被切碎,一个知识点跨多个块,检索时容易漏
- 太大(如 1500-2000):语义被稀释,且一次塞不了几块进上下文
- 中文场景经验值 :500-1000 字符。本项目取 800,约 200-300 tokens,对课程笔记(段落式表达)是合理选择
调参建议 :不同领域最佳值不同。代码文档可以小(按函数块,300-500),长篇说明文可以大(800-1200)。建议用评估集对比不同值的效果。
参数二: chunk_overlap (重叠区)
含义 :相邻两个块之间的重叠字符数。
为什么需要重叠 :假设有一句话"RAG 的三步是索引、检索、生成",如果刚好在第 8 个字符切分,前一块是"RAG 的三步是索引",后一块是"、检索、生成",两块都不完整。加了 overlap 后,前一块会多包含后续 100 个字符,保证关键信息不在边界丢失。
取值经验 :一般为 chunk_size 的 10%-20%。本项目 100/800 = 12.5% ,在合理范围。
代价 :overlap 会增加总块数和存储成本,但换来的检索精度提升通常值得。
参数三: separators (分隔符优先级列表)
这是 RecursiveCharacterTextSplitter 的灵魂 ,也是它比 CharacterTextSplitter 强大的根本原因。
工作原理 :不是一个分隔符切到底,而是 按优先级从高到低递归尝试 :
切分流程: 1. 尝试用最高优先级 "\n#"(一级标题)切分 → 如果每段都 ≤ chunk_size,完成 → 否则进入第 2 步 2. 对超长段,用 "\n##"(二级标题)继续切 3. 再用 "\n###"(三级标题) 4. 再用 "\n\n"(段落) 5. 再用 "\n"(换行) 6. 再用 "。"(中文句号) 7. 再用 ";"(中文分号) 8. 再用 "."(英文句号) 9. 最后兜底 ""(按字符硬切)本项目的 separators 设计:
separators=["\n#", "\n##", "\n###", "\n\n", "\n", "。", ";", ".", ""]- 前三个 "\n#" , "\n##" , "\n###" : 按 Markdown 标题切 ,优先保证章节完整性。课程笔记多是 Markdown,这样切能让一块内容属于同一章节
- "\n\n" : 按段落切 ,非 Markdown 文档也能用
- "\n" : 按行切 ,进一步细分
- "。" ";" : 中文句号和分号 ,这是中文场景的关键改进(LangChain 默认 separators 没有中文标点)
- "." :英文句号,兼容英文内容
- "" :兜底,实在切不开就按字符硬切
3.5 中文场景的分隔符优化技巧
LangChain 默认的 separators:
separators=["\n\n", "\n", " ", ""] # 默认值,只有英文/通用分隔符默认配置切中文时,会跳过 "。" ";" 这些中文句末标点,直接按空格(中文几乎不用空格)或字符硬切。结果就是一句话会被从中间切断,"RAG 是检索增强生成" 可能被切成 "RAG 是检索增" + "强生成"。
本项目在 "\n" 和 "." 之间插入 "。" 和 ";" ,这样中文句子会在句号 。 处优先切分,保证每块包含完整的句子,大幅提升检索精度。
separators=["\n#", "\n##", "\n###", "\n\n", "\n", "。", ";", ".", ""]除此之外,如果文档里有大量列表项,可以加入 "-" 、 "1." 、 "2." 等列表分隔符;如果是代码文档,可以加入 "def " 、 "class " 、 "function " 等代码结构分隔符。
四、loader.py 完整代码解析
4.1 文档加载部分解析
现在把上面的知识串起来,逐段解析本项目的 loader.py 。
第一部分:导入与路径定位
from langchain_community.document_loaders import ( TextLoader, DirectoryLoader, Docx2txtLoader, PyPDFLoader, UnstructuredPowerPointLoader ) from langchain_text_splitters import RecursiveCharacterTextSplitter import os 获取项目根目录的绝对路径 BASE_DIR = os.path.join(os.path.dirname(file), "..")设计要点 :
- BASE_DIR 用 __file__ + os.path.dirname + ".." 拼出项目根目录的绝对路径。这样无论从哪个目录启动脚本,路径都不会错
- 导入语句把用到的 5 个加载器一次性引入, RecursiveCharacterTextSplitter 来自 langchain_text_splitters (注意是 text_splitters 不是 community)
第二部分:5 个 DirectoryLoader 的设计
# 1. Markdown 加载器 md_loader = DirectoryLoader( path=os.path.join(BASE_DIR, "date/notes/"), glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"} ) 2. Word 加载器 docx_loader = DirectoryLoader( path=os.path.join(BASE_DIR, "date/notes/"), glob="**/*.docx", loader_cls=Docx2txtLoader, ) 3. PDF 加载器 pdf_loader = DirectoryLoader( path=os.path.join(BASE_DIR, "date/slides"), glob="**/*.pdf", loader_cls=PyPDFLoader, ) 4. PPT 加载器 pptx_loader = DirectoryLoader( path=os.path.join(BASE_DIR, "date/slides"), glob="**/*.pptx", loader_cls=UnstructuredPowerPointLoader, ) 5. 纯文本加载器 txt_loader = DirectoryLoader( path=os.path.join(BASE_DIR, "date/notes/"), glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"} )设计要点 :
- 按文件类型分别建加载器,而不是用一个加载器处理所有格式。因为每种格式的底层解析库不同, loader_cls 必须指定对应的类
- 笔记类(md/docx/txt)统一从 date/notes/ 加载,课件类(pdf/pptx)从 date/slides/ 加载,目录划分清晰
- glob="**/*" 的 ** 保证递归扫描子目录
- 只有 TextLoader 传了 encoding="utf-8" ,因为 Docx2txtLoader 和 PyPDFLoader 内部已处理编码
第三部分:合并加载
all_docs = md_loader.load() + docx_loader.load() + pdf_loader.load() + pptx_loader.load() + txt_loader.load()设计要点 :用 + 把 5 个列表拼接成一个 List[Document] 。此时 all_docs 里每个 Document 是一个文件(PDF 是一页)的完整内容。
4.2 文档分块部分解析
splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=100, separators=["\n#", "\n##", "\n###", "\n\n", "\n", "。", ";", ".", ""] ) chunks = splitter.split_documents(all_docs)逐行解读 :
1. chunk_size=800 :每块最大 800 字符。这个值是针对中文课程笔记的经验值,约 200-300 tokens,能容纳 2-3 个完整段落
2. chunk_overlap=100 :相邻块重叠 100 字符,约为 chunk_size 的 12.5%。保证跨块的关键信息不丢失
3. separators 列表 :分两层理解
- 结构层 : "\n#" , "\n##" , "\n###" 按 Markdown 标题切,保证章节完整性
- 段落层 : "\n\n" , "\n" 按段落和行切
- 句子层 : "。" , ";" , "." 按中英文句末标点切,保证句子完整
- 兜底层 : "" 按字符硬切,确保一定能切到 chunk_size 以下
4. split_documents(all_docs) :注意调用的是 split_documents 而不是 split_text 。前者接收 List[Document] , 会保留每个 Document 的 metadata (source、page 等),切分后的每个小块都继承父文档的元数据。这对 RAG 的来源引用至关重要
执行完后, chunks 是一个 List[Document] ,每个元素是一个约 800 字符的文本块,带着来源元数据。这个 chunks 会被 vectorstore.py#L11 导入,用于构建向量库。
4.3 模块化设计解析
设计一:模块级代码,导入即执行
# 在 vectorstore.py 中 from src.loader import chunks # 这一行会触发 loader.py 的全部加载和分块逻辑all_docs = ... 和 chunks = ... 是模块级代码(不在函数里),导入 loader.py 时立即执行。
优点:下游模块(vectorstore、rag_chain)只需一行 import 就能拿到处理好的 chunks,不用关心加载细节, 关注点分离 做得很好。
缺点:加载大量文档时启动慢。本项目文件少,影响可忽略;生产环境若有上千文件,应改为懒加载或缓存机制。
设计二:全局 chunks 导出模式
chunks 作为模块级变量,天然是单例。整个项目共享同一份分块结果,避免重复加载。配合 vectorstore.py 的"已存在则加载、不存在则构建"逻辑,首次构建后向量库会持久化,后续启动连加载分块都跳过了。
设计三:绝对路径定位
BASE_DIR = os.path.join(os.path.dirname(__file__), "..")这是 Python 定位项目资源的标准写法。 __file__ 是当前文件的路径, os.path.dirname 取所在目录( src/ ), ".." 回到上一级(项目根目录)。无论从哪里执行 python -m src.app ,路径都正确。
五、实战踩坑与优化经验
5.1 中文编码坑
现象 :加载 .md 或 .txt 文件时,中文变成乱码,或者直接抛 UnicodeDecodeError 。
原因 :Windows 系统默认编码是 GBK,而 Markdown/文本文件通常是 UTF-8 编码。TextLoader 不传 encoding 参数时,会用系统默认编码读取。
错误写法:
loader = TextLoader("笔记.md") # Windows 下大概率乱码正确写法:
loader = TextLoader("笔记.md", encoding="utf-8") # 显式指定5.2 相对路径坑
现象 :在项目根目录运行 python src/app.py 正常,但在 src/ 目录运行 python app.py 就报 FileNotFoundError 。
原因 :相对路径(如 "date/notes/" )是相对于 当前工作目录 (CWD)解析的,不是相对于脚本文件。CWD 取决于你在哪里执行命令,所以会飘。
正确写法:
loader = DirectoryLoader(path="date/notes/", ...) # CWD 一变就找不到错误写法:
loader = DirectoryLoader(path="date/notes/", ...) # CWD 一变就找不到5.3 chunk_size 调参经验
现象 :RAG 回答不准,要么答非所问,要么信息不全。
排查思路 :chunk_size 直接影响检索精度,建议用同一组测试问题对比不同值的效果:
| chunk_size | 检索表现 | 生成表现 | 适用场景 |
|---|---|---|---|
| 200 | 召回多但碎片化,语义不完整 | 上下文零散,模型难综合 | 代码片段、QA 对 |
| 800(本项目) | 平衡,单块含 2-3 段 | 上下文连贯,模型好理解 | 通用、课程笔记 |
| 1500 | 召回少但单块信息密集 | 上下文长,可能稀释重点 | 长篇说明文 |
没有万能值, 必须用评估集对比 。建议准备 10-20 个典型问题,对比不同 chunk_size 下的回答质量。
5.4 PDF 页眉页脚污染问题
现象 :检索到的上下文里混入"第 3 页 / 共 20 页"等页眉页脚,干扰模型理解。
原因 : PyPDFLoader 会把页眉页脚也作为正文提取,这些内容向量化后会污染语义。
解决方案 (本项目未做,可作为优化方向):
- 加载后用正则清洗 metadata 和 page_content
- 或换用 UnstructuredPDFLoader ,它能区分页眉页脚
- 或用 PDF 预处理库(如 pdfplumber)先清洗再加载