LangChain 学习笔记(四):文档加载、文本切分与 Embedding,一次搞懂 RAG 入库前处理

📅 2026/7/10 3:32:56 👁️ 阅读次数 📝 编程学习
LangChain 学习笔记(四):文档加载、文本切分与 Embedding,一次搞懂 RAG 入库前处理

LangChain 学习笔记(四):文档加载、文本切分与 Embedding,一次搞懂 RAG 入库前处理

这是 LangChain 学习笔记系列的第四篇。前三篇主要整理了基础调用链路、工具调用、结构化输出、Few-shot 和 RAG 入门。从这一篇开始,进入 RAG 真正落地时绕不开的工程细节:文档如何加载、如何切分、如何变成向量,以及这些步骤为什么会影响最终检索效果。

1. 为什么这一篇很重要

很多人刚开始学 RAG,会把注意力放在:

怎么调用大模型? 怎么用向量数据库? 怎么写 RAG chain?

但真正做项目时,很快会发现:

RAG 效果差,很多时候不是模型不行, 而是文档处理、文本切分和 embedding 阶段就已经出问题了。

比如:

PDF 里的图片没有被解析; 一个完整 QA 被切成两半; 一个 chunk 里混了多个不相关主题; chunk_size 太小导致上下文不完整; chunk_size 太大导致检索不精准; query embedding 和 document embedding 没搞清楚; metadata 里没有保留 source,回答无法追溯来源。

所以这一篇要解决的问题是:

RAG 里的外部文档,在进入向量数据库之前,到底要经历哪些处理?

主线是:

原始文件 -> Document -> 文本切分 -> chunk -> Embedding -> 准备进入 VectorStore

2. 从前三篇衔接到这里

前三篇里,我们已经建立了这条主线:

第一篇: 如何组织输入并调用模型。 第二篇: 如何让模型调用工具,并稳定输出结构化结果。 第三篇: 如何让模型参考示例和外部知识回答。

第三篇讲到 RAG 的时候,重点是:

先检索,再生成。

但当时只是总览:

文档加载 文本切分 Embedding VectorStore Retriever LLM

这一篇专门展开前半段:

文档加载 文本切分 Embedding

也就是:

文档进入向量库之前,应该怎么处理。

3. RAG 入库前的完整流程

一个典型 RAG 系统在入库前大概是:

PDF / Markdown / TXT / HTML / Word | v Document Loader | v Document(page_content, metadata) | v Text Splitter | v 多个 chunk Document | v Embedding Model | v document embedding | v VectorStore

这一篇先讲到 embedding 为止。

下一篇会继续讲:

这些 embedding 和 metadata 存进 Redis / RediSearch 后,到底长什么样。

4. Document 是什么

LangChain 里,文档通常被包装成Document

fromlangchain_core.documentsimportDocument doc=Document(page_content="LangChain 是一个用于开发 LLM 应用的框架。",metadata={"source":"course-note.md","category":"LangChain","page":1,})

Document主要有两部分:

page_content: 真正要给模型检索和理解的正文。 metadata: 文档的附加信息,比如来源、页码、分类、编号。

在 RAG 里,metadata很重要。

它可以用来:

追踪答案来源; 做 metadata 过滤; 显示引用页码; 区分文档类型; 保留原始文件路径; 后续做权限控制。

比如:

Document(page_content="Redis 支持向量搜索。",metadata={"source":"redis-vector.md","category":"vectorstore","num":8})

后面存进向量库时,通常会变成:

text = page_content metadata = source / category / num embedding = page_content 对应的向量

5. Document Loader 是什么

Document Loader 的作用是:

把不同来源的文件,加载成 LangChain 标准 Document。

比如:

TXT 文件 PDF 文件 Markdown 文件 网页 CSV Word 数据库记录

这些原始格式不同,但加载后都可以统一成:

Document(page_content="...",metadata={...})

这就是标准化的意义。

只要变成Document,后面就都能走同一套流程:

split_documents embedding vector_store.add_documents retriever.invoke

6. PDF 文档加载为什么容易出问题

PDF 是 RAG 里最常见、也最容易踩坑的文档类型。

因为 PDF 可能有很多形态:

纯文本 PDF 扫描版 PDF 图片 + 文本混合 PDF 表格型 PDF 双栏论文 PDF PPT 导出的 PDF 带复杂图表的 PDF

如果是纯文本 PDF,普通 PDF loader 通常能抽取文本。

流程是:

PDF 文本层 -> PDF Loader 抽取文字 -> Document

但如果 PDF 里的关键信息是图片、截图、流程图、扫描件,普通文本解析器可能抽不到。

这时会出现:

PDF 页面看起来有内容; 但 loader 抽出来的 page_content 很少,甚至为空。

根本原因是:

普通 PDF loader 主要读文本层; 图片里的内容不是文本层。

7. PDF 里有图片怎么办

如果 PDF 里有图片或扫描页,常见处理方式有三种。

7.1 OCR

OCR 是把图片里的文字识别出来。

流程:

PDF 页面 -> 渲染成图片 -> OCR 识别文字 -> 转成 Document

适合:

扫描版 PDF; 图片里主要是文字; 表格不复杂。

7.2 多模态 LLM

如果图片里有流程图、架构图、截图、复杂图表,可以交给支持视觉的模型。

流程:

PDF 页面 / 图片 -> 多模态模型理解图片 -> 生成文字描述 -> 转成 Document

它的优势是:

不仅能识别文字,还能理解图里表达的关系。

比如图里是:

用户 -> Retriever -> VectorStore -> LLM

OCR 可能只识别出文字,多模态模型可以总结成:

这张图表达了 RAG 查询流程:用户问题先进入检索器,从向量库取回相关文档,再交给 LLM 生成答案。

7.3 混合方案

真实项目里更推荐混合:

PDF 文本层 -> 文本解析 PDF 图片/扫描页 -> OCR 或多模态模型 两者合并 -> Document

也就是说,不是简单地:

PDF 直接给 LLM

而是:

根据 PDF 内容类型选择解析方式。

8. 为什么不能直接把整篇文档塞给模型

假设你有一个 100 页 PDF。

如果直接塞给模型,会有几个问题:

超过模型上下文窗口; 大量内容和问题无关; token 成本高; 模型容易抓不住重点; 无法精准定位答案来源。

所以 RAG 不会直接把整篇文档给模型。

它会先把文档切成小块:

Document -> chunk1 -> chunk2 -> chunk3

查询时只召回最相关的几个 chunk。

这就是文本切分的意义。

9. 文本切分要解决什么问题

文本切分的目标不是简单地“切小”。

它要同时兼顾:

chunk 不能太长; chunk 不能太短; 语义尽量完整; 检索时能准确召回; 生成时上下文足够回答问题。

切太小:

检索很精确,但信息不完整。

切太大:

信息完整,但检索不精准,容易混入无关内容。

所以切分是一种工程取舍。

10.chunk_size是什么

chunk_size表示每个 chunk 的目标大小。

例如:

chunk_size=800

意思不是每块都必须等于 800,而是:

尽量让每个 chunk 不超过或接近这个大小。

具体怎么算,取决于length_function或 tokenizer。

如果用:

length_function=len

那一般就是按字符数算。

如果用:

from_tiktoken_encoder(...)

那就更接近按 token 算。

11.chunk_overlap是什么

chunk_overlap表示相邻 chunk 之间重复多少内容。

例如:

chunk_size=800chunk_overlap=100

意思是:

chunk1 的结尾一部分,会出现在 chunk2 的开头。

为什么需要 overlap?

因为语义可能刚好被切在边界上。

比如:

chunk1: Redis 是一个内存数据库,支持多种数据结构。它也可以通过 RediSearch... chunk2: ...实现全文搜索和向量搜索。

如果没有 overlap,chunk2可能缺少前文。

有 overlap 后:

chunk2 会带上前一段末尾内容。

但 overlap 太大也有问题:

重复内容变多; 向量库体积变大; 检索结果更容易重复; token 成本增加。

常见经验:

chunk_overlap 通常设为 chunk_size 的 10% 到 20%。

例如:

chunk_size=800 chunk_overlap=100

12. CharacterTextSplitter:按字符/分隔符切

最基础的是按字符和分隔符切。

fromlangchain_text_splittersimportCharacterTextSplitter text_splitter=CharacterTextSplitter(separator="\n\n",chunk_size=800,chunk_overlap=100,length_function=len,)

参数含义:

separator: 优先按什么分隔符切。这里是双换行。 chunk_size: 目标块大小。 chunk_overlap: 相邻块重叠大小。 length_function: 如何计算文本长度。len 表示字符数。

它适合:

结构比较简单; 段落分明; 不需要复杂递归策略。

但如果一个段落特别长,它可能切得不够自然。

13. 按 token 切:为什么更贴近模型

大模型的上下文限制和计费通常按 token 算,不是按字符算。

比如中文、英文、标点、代码,token 计算方式都不一样。

所以有时更适合按 token 控制长度:

fromlangchain_text_splittersimportCharacterTextSplitter text_splitter=CharacterTextSplitter.from_tiktoken_encoder(encoding_name="cl100k_base",chunk_size=500,chunk_overlap=80,)

这里:

encoding_name="cl100k_base"

表示使用 tiktoken 的一种编码方式。

chunk_size=500更接近:

500 个 token。

适合:

严格控制模型输入长度; 控制成本; 避免上下文超限。

14. RecursiveCharacterTextSplitter:递归切分

实际项目里最常见的是:

fromlangchain_text_splittersimportRecursiveCharacterTextSplitter text_splitter=RecursiveCharacterTextSplitter(separators=["\n\n","\n","。","!","?",";",","," ",""],chunk_size=800,chunk_overlap=100,length_function=len,is_separator_regex=False,)

它的逻辑是:

先按大分隔符切; 如果切出来仍然太长,再按更小分隔符切; 如果还太长,继续往下; 最后实在不行按字符硬切。

例如分隔符列表:

\n\n 段落 \n 行 。 句号 ! 感叹号 ? 问号 ; 分号 , 逗号 空格 单词间隔 "" 最后兜底按字符切

这就是“递归”的意思。

它比单一分隔符更适合普通中文文档。

15.separators怎么选

separators是切分优先级列表。

普通中文文档推荐:

separators=["\n\n","\n","。","!","?",";",","," ",""]

Markdown 文档可以优先按标题切:

separators=["\n# ","\n## ","\n### ","\n\n","\n","。",""]

代码文档不要简单按句号切,更适合按:

函数 类 代码块 空行

因为代码的语义单位不是自然语言句子。

16.is_separator_regex是什么

is_separator_regex=False

表示separators里的内容是普通字符串。

如果你想用正则表达式作为分隔符,可以设为:

is_separator_regex=True

比如想按某些格式化标题切:

第1章 xxx 第2章 xxx

可以考虑正则。

但要注意:

结构特别明确的文档,最好先自己做结构化解析; 不要完全依赖通用 splitter。

17. 如果语义被切断怎么办

这是 RAG 里非常常见的问题。

解决办法有几种:

增加 chunk_overlap; 调大 chunk_size; 优化 separators; 先按文档结构切; 对特殊格式单独解析。

比如中文文档只用:

separators=["\n\n","\n"," "]

可能不够。

因为中文文本没有空格分词。

更合理是加中文标点:

separators=["\n\n","\n","。","!","?",";",","," ",""]

18. 如果一个段落太长怎么办

如果一个段落特别长,且没有合适分隔符,递归切分器最后还是会硬切。

这不是 bug,而是兜底机制。

否则它无法保证 chunk 不超过长度限制。

解决方式:

加入更细的中文标点分隔符; 用 token-based splitter; 先清洗 PDF 抽取文本; 对特殊格式做结构化解析。

例如:

text_splitter=RecursiveCharacterTextSplitter.from_tiktoken_encoder(encoding_name="cl100k_base",separators=["\n\n","\n","。","!","?",";",","," ",""],chunk_size=500,chunk_overlap=80,)

19. QA 文档应该怎么切

如果文档天然是 QA 格式,不建议完全依赖通用 splitter。

比如:

问题:LangChain 是什么? 答案:LangChain 是一个用于开发 LLM 应用的框架。 问题:RAG 是什么? 答案:RAG 是检索增强生成。

最好的策略是:

先按 QA 边界切; 每个 QA 作为一个 Document; 如果某个答案太长,再在这个 QA 内部二次切分; 每个子 chunk 都保留 question 和 qa_id。

示例:

importrefromlangchain_core.documentsimportDocument pattern=r"问题[::]\s*(.*?)\n答案[::]\s*(.*?)(?=\n问题[::]|\Z)"documents=[]fori,matchinenumerate(re.finditer(pattern,text,flags=re.S)):question=match.group(1).strip()answer=match.group(2).strip()documents.append(Document(page_content=f"问题:{question}\n答案:{answer}",metadata={"type":"qa","qa_id":i,"question":question,}))

这样可以保证:

一个 QA 不会被随便拆散。

如果答案太长,再二次切:

inner_splitter=RecursiveCharacterTextSplitter(separators=["\n\n","\n","。","!","?",";",","," ",""],chunk_size=500,chunk_overlap=80,)final_docs=[]forqa_docindocuments:question=qa_doc.metadata["question"]chunks=inner_splitter.split_text(qa_doc.page_content)foridx,chunkinenumerate(chunks):final_docs.append(Document(page_content=f"问题:{question}\n答案片段:{chunk}",metadata={**qa_doc.metadata,"chunk_index":idx,}))

核心原则:

结构边界优先; 长度控制兜底。

20. chunk_size 太大会怎样

如果chunk_size太大,可能出现:

一个 chunk 里包含多个问题和答案; 一个 chunk 混入多个主题; 检索时召回不够精准; 模型回答时引用了无关内容。

例如:

chunk: 问题1:Redis 是什么? 答案1:... 问题2:Pinecone 是什么? 答案2:... 问题3:FAISS 是什么? 答案3:...

用户只问 Redis,但 chunk 里还有 Pinecone 和 FAISS。

模型可能被干扰。

21. chunk_size 太小会怎样

如果chunk_size太小,可能出现:

答案被切碎; 检索到的片段缺少上下文; 模型只能看到半句话; 需要更多 chunk 才能拼出完整答案。

例如:

chunk1: RediSearch 是 Redis 的搜索模块, chunk2: 它支持全文检索、标签过滤、数字范围过滤和向量搜索。

用户问 RediSearch 支持什么能力,如果只召回 chunk1,就回答不完整。

22. 怎么判断切分效果好不好

不要只看代码是否能运行,还要抽样检查 chunk。

可以打印前几个 chunk:

chunks=text_splitter.split_documents(docs)fori,chunkinenumerate(chunks[:5]):print("="*40)print("chunk",i)print(chunk.page_content)print(chunk.metadata)

检查:

是否语义完整; 是否有明显断句; 是否混入多个主题; metadata 是否保留 source / page; chunk 长度是否合理; QA 是否被拆散。

资深一点的做法是:

准备一批测试问题; 观察每个问题能否检索到正确 chunk; 根据召回结果反调 chunk_size、overlap 和 separators。

这比拍脑袋设参数更可靠。

23. Embedding 是什么

Embedding 是把文本转成向量。

例如:

"Redis 支持向量搜索"

会变成类似:

[0.012, -0.384, 0.771, ...]

真实向量维度可能是几百到几千维。

语义相近的文本,向量距离会更近。

比如:

Redis 可以做向量检索 Redis 支持语义搜索

它们字面不完全一样,但语义接近,所以向量也会更接近。

24. document embedding 是什么

入库时,每个 chunk 都会生成一个 document embedding。

流程:

Document chunk -> embedding model -> document embedding -> 存入 VectorStore

比如:

chunk text: "Redis 支持向量搜索,可以用于 RAG 系统中的语义检索。"

生成:

document_embedding = [0.12, -0.03, 0.88, ...]

后面这个向量会跟原文一起存入向量库。

25. query embedding 是什么

查询时,用户问题也要转成向量。

用户问题: "Redis 怎么做语义检索?"

调用 embedding 模型后得到:

query_embedding = [0.11, -0.01, 0.85, ...]

然后系统会拿 query embedding 去和 document embedding 比较相似度。

所以:

document embedding:文档片段的向量表示。 query embedding:用户问题的向量表示。

它们必须在同一个向量空间里比较。

26. query embedding 和 document embedding 的关系

RAG 检索阶段可以理解成:

query embedding 去向量库里找最接近的 document embedding

例如:

query: "Redis 向量索引是什么?" doc1: "Redis key 是真实数据地址。" doc2: "RediSearch Index 可以基于 embedding 做向量相似搜索。" doc3: "PromptTemplate 用于构造提示词。"

向量检索大概率返回:

doc2

因为它和 query 的语义最接近。

27. Embedding 和普通关键词搜索有什么区别

关键词搜索关注字面匹配。

例如用户问:

语义检索怎么做?

文档写的是:

向量搜索可以找到意思相近的文本。

关键词搜索可能匹配不到,因为没有共同关键词。

Embedding 检索更关注语义:

语义检索 ≈ 向量搜索

所以可以找到。

但 embedding 也不是万能的。

它可能受这些因素影响:

embedding 模型能力; chunk 切分质量; query 表达是否清楚; 文档语言和 query 是否一致; 领域术语是否被模型理解; metadata 过滤是否合理。

28. Embedding 模型怎么选

选择 embedding 模型要看:

语言支持; 向量维度; 成本; 速度; 领域适配; 是否和向量库兼容; 是否支持你的部署环境。

课程里常见:

fromlangchain_openaiimportOpenAIEmbeddings embeddings=OpenAIEmbeddings(model="text-embedding-3-large")

注意:

同一个向量库里的 document embedding 和 query embedding 应该用同一个 embedding 模型生成。

否则向量空间不一致,检索结果会很差。

29. 入库前处理的完整代码骨架

下面是一个简化版流程。

fromlangchain_community.document_loadersimportTextLoaderfromlangchain_text_splittersimportRecursiveCharacterTextSplitterfromlangchain_openaiimportOpenAIEmbeddings# 1. 加载文档loader=TextLoader("docs/course.md",encoding="utf-8")docs=loader.load()# 2. 切分文档text_splitter=RecursiveCharacterTextSplitter(separators=["\n\n","\n","。","!","?",";",","," ",""],chunk_size=800,chunk_overlap=100,length_function=len,)chunks=text_splitter.split_documents(docs)# 3. 定义 embedding 模型embeddings=OpenAIEmbeddings(model="text-embedding-3-large")# 4. 后续会把 chunks 和 embeddings 交给 VectorStore# vector_store.add_documents(chunks)

这段代码到这里还没有真正进入向量库。

它完成的是:

文档加载 文本切分 准备 embedding 模型

下一篇会继续:

这些 chunks 和 embedding 怎么存进 Redis / RediSearch。

30. 容易混淆的点

30.1 Document 是原始文件吗

不是。

Document 是 LangChain 对文本内容的统一包装。

一个 PDF 可以加载成多个 Document。

一个 Document 也可以再被切成多个 chunk Document。

30.2 chunk_size 越大越好吗

不是。

太大容易混入多个主题,检索不精准。

30.3 chunk_size 越小越好吗

也不是。

太小会破坏语义完整性。

30.4 overlap 越大越好吗

不是。

overlap 太大会增加重复、成本和检索冗余。

30.5 PDF 解析不到图片是不是模型问题

不一定。

很多时候是 PDF loader 只抽文本层,图片内容没有被 OCR 或多模态模型处理。

30.6 query embedding 和 document embedding 是两个模型吗

通常不是。

它们应该由同一个 embedding 模型生成,只是输入不同:

query embedding 输入用户问题; document embedding 输入文档 chunk。

31. 工程实践:RAG 效果很多时候输在入库阶段

很多人刚开始做 RAG 时容易觉得:

RAG = 向量库 + LLM

但工程里更准确是:

RAG = 文档处理质量 + 检索质量 + Prompt 组织 + 模型生成

如果文档入库阶段做得不好:

PDF 内容丢失; chunk 语义破碎; metadata 缺失; embedding 不匹配; chunk 粒度不合理;

后面再换更贵的模型,也不一定能救回来。

所以资深一点的 RAG 优化,往往不是先调模型,而是先问:

文档解析完整吗? chunk 是否合理? 问题能否召回正确 chunk? metadata 是否能过滤? 是否需要结构化切分? 是否需要混合检索?

这就是这一篇的工程意义。

32. 本篇总结

这一篇主要整理了 RAG 入库前的三个关键步骤:

第一,文档加载:

把 PDF、Markdown、TXT 等原始文件变成 Document。

第二,文本切分:

把长文档切成适合检索和生成的 chunk。

第三,Embedding:

把 chunk 和用户问题转成向量,方便后续做语义检索。

几个关键结论:

Document = page_content + metadata。 PDF 解析不一定能拿到图片里的内容。 文本切分要兼顾长度和语义完整。 RecursiveCharacterTextSplitter 是常用通用方案。 QA 文档最好先按结构切,再按长度兜底。 chunk_size 和 chunk_overlap 都是工程取舍。 document embedding 用于表示文档 chunk。 query embedding 用于表示用户问题。 两者要在同一个向量空间里比较。

一句话概括:

RAG 的效果不是从检索器才开始决定的,文档加载、文本切分和 embedding 阶段已经决定了很大一部分上限。

33. 下一篇预告

下一篇会继续整理:

Redis 向量存储、RediSearch Index 与 metadata 过滤

重点回答这些问题:

Document 存进 Redis 后到底长什么样? Redis key、hash、RediSearch Index 是什么关系? index_name 为什么要起名字? metadata_schema 是做什么的? TAG、NUMERIC、TEXT、VECTOR 分别是什么? query vector 是怎么在 RediSearch Index 里找相似 key 的? 为什么知道 key 可以直接查,但 RAG 还需要 index?

也就是从“文档如何变成向量”继续走到“向量和 metadata 如何被存储和搜索”。