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 -> 准备进入 VectorStore2. 从前三篇衔接到这里
前三篇里,我们已经建立了这条主线:
第一篇: 如何组织输入并调用模型。 第二篇: 如何让模型调用工具,并稳定输出结构化结果。 第三篇: 如何让模型参考示例和外部知识回答。第三篇讲到 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.invoke6. 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 -> LLMOCR 可能只识别出文字,多模态模型可以总结成:
这张图表达了 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=10012. 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 如何被存储和搜索”。