做RAG项目,很多人一上来就盯向量库、调Embedding模型、比检索召回率,真到代码跑起来才发现,脏活累活全堆在数据预处理上。我经手过好几个RAG知识库项目,从早期demo到能顶住线上检索压力,最终复盘结论都出奇一致:RAG的上限不在模型,而在喂给模型的文本长什么样。而文本长什么样,基本由两个环节决定——Document Loader(文档加载器)和Text Splitter(文本分割器)。
这篇内容不是概念扫盲,是把这两块掰开揉碎讲透:Loader怎么选、加载时坑在哪;Splitter到底在解决什么问题、chunk_size和overlap怎么定;最后给一套能直接抄作业的完整预处理流水线。适合刚上手LangChain做RAG的同学,也适合已经在跑知识库但召回效果一直不理想、怀疑是数据没弄干净的从业者。里面大部分是我在真实项目里踩过坑之后沉淀下来的做法,照着做,能少走不少弯路。
1. 先看清数据预处理在RAG链路里的真实位置
1.1 数据预处理为什么能决定RAG的天花板
一条标准RAG链路可以拆成五段:文档加载 → 文本切分 → 向量化入库 → 检索召回 → 生成回答。向量化、检索、生成每一步都有大量调优空间,但最容易被忽视的是前两段——数据进库长什么样,直接决定后续所有环节的结果。
举例更直观:一段2000字的技术文档,如果按500 token一块切分,切出来4个chunk;检索时用户问“这个接口的鉴权参数怎么配”,如果恰好答案分散在第2个和第3个chunk里,召回出来的内容就是两截不完整的片段,给到大模型只能靠脑补拼接,幻觉风险直线上升。反过来切得太碎,比如每块50 token,虽然每个片段语义纯粹,但检索结果常常缺乏上下文,模型拿到的是断章取义的碎片,答案质量照样差。
所以数据预处理不是“把文字塞进数据库之前随便处理一下”,它决定了检索的颗粒度和内容的语义完整性之间的平衡。这也是为什么很多团队用了同一个向量库、同一个模型,效果却天差地别——差距往往在文档被切分的那一刻就已经注定了。
1.2 Loader和Splitter分别解决什么问题
一句话概括:Loader负责把各种格式的原始文件变成程序能统一处理的文本对象,Splitter负责把这堆长文本切成适合检索和向量化的短片段。
Loader面对的世界很杂:PDF、Word、Markdown、HTML、CSV、JSON,甚至还有扫描件和网页。它们的共同点是都需要被转成一个标准结构——LangChain里的Document对象。这个对象有两个核心字段:page_content存正文文本,metadata存来源、页码、标题等辅助信息。有了统一结构,下游才不用管你原始文件是PDF还是网页。
Splitter面对的问题则更细腻:切分点选在哪,才能让每个片段尽量语义完整?一段话被拦腰截断后,上下文信息丢失多少?Embedding模型对输入长度有上限,切出来的块不能超限;但块太小又会导致检索时信息不足。这中间的拿捏,就是Splitter的核心价值。
两个环节都不直接参与生成回答,但它们把输入质量定了调——进库的文本什么德行,回库的答案就什么德性。
2. Document Loader深度拆解:从文件到标准文本
2.1 适配器模式:为什么LangChain要搞出几十种Loader
LangChain的Loader设计本质是适配器模式:原始文件是千奇百怪的“电压”,Loader就是各种规格的“充电头”,统一输出稳定的“直流电”——List[Document]。好处是业务代码只需要依赖Document这个稳定结构,换数据源时不用改下游逻辑。你今天喂PDF,明天换成网页抓取,只需要换Loader,切分、向量化、检索那一整套代码全部复用。
理解了这一点,你会发现自己并不需要记住全部几十种Loader,只需要清楚每个Loader的适用场景和关键参数,用到时查文档就行。真正重要的,是理解每个Loader底层在做什么、会产生什么坑。
2.2 常见Loader的选型与核心参数
我按实际使用频率,把常用Loader分几类,并标注了它们真正的适用场景和容易出问题的地方。
纯文本类:TextLoader
最基础也最容易踩坑。它的核心功能就是读文件内容,但编码问题能坑死一堆人。Windows上导出的txt经常是GBK编码,直接默认读会乱码。所以务必显式指定编码:
from langchain_community.document_loaders import TextLoader loader = TextLoader("notes.txt", encoding="utf-8", autodetect_encoding=True) docs = loader.load()autodetect_encoding=True可以自动探测编码,但速度稍慢,且遇到极少数双编码混杂的文件会失灵。我给个实在的建议:宁可加载时报错,也不要静默产生乱码内容。乱码文本进了向量库,召回的永远是乱码,特征还挺稳定,排查起来极其恶心。所以编码参数要显式设置,不要靠猜。
PDF类:PyPDFLoader / PyMuPDFLoader / PDFPlumberLoader
PDF是RAG场景里最普遍也最麻烦的格式。三种Loader差别很大:
PyPDFLoader:底层用pypdf,纯Python解析,速度一般,对标准文本型PDF表现稳定。PyMuPDFLoader:底层是fitz(MuPDF),解析速度快,对复杂版式、带有较多文本块的PDF表现更好,而且能顺带提取图片位置等元数据。PDFPlumberLoader:底层是pdfplumber,对表格提取方面有明显优势,文本按行、按坐标解析,能保住表格行列结构。如果你处理的PDF里表格占比高,这个更合适。
from langchain_community.document_loaders import PyMuPDFLoader loader = PyMuPDFLoader("report.pdf") docs = loader.load() # 每个Document的metadata里通常有page_number和source注意一个大坑:扫描版PDF本质是图片,这几种Loader全都无能为力,提取出来的要么是空文本,要么是乱码。这种情况必须走OCR路线,后面在常见问题里展开。
目录批量加载:DirectoryLoader
做知识库时往往有成百上千个文件,单个Loader一个个加载不可接受。DirectoryLoader可以按通配符批量加载目录下的所有文件,还能指定每个文件类型对应的Loader。
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader pdf_loader = DirectoryLoader( path="docs/", glob="**/*.pdf", loader_cls=PyPDFLoader, show_progress=True, ) md_loader = DirectoryLoader( path="docs/", glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, )glob支持**递归匹配子目录,loader_cls指定处理器,loader_kwargs透传给具体Loader。还有一个use_multithreading=True参数,实测对纯文本文件有效,但对PDF不一定,因为pdfplumber这类库内部不是线程安全的,多线程下容易随机崩溃。建议先单线程跑通,再考虑要不要优化速度。
结构化数据:CSVLoader / JSONLoader
CSV和JSON在知识库中常见于产品说明书、业务报表、配置手册等。CSVLoader的csv_args可以控制分隔符和编码风格,source_column指定把哪一列当作内容来源记录进metadata。
from langchain_community.document_loaders import CSVLoader loader = CSVLoader( "products.csv", csv_args={"delimiter": ",", "encoding": "utf-8"}, source_column="product_id", )JSONLoader更值得花点心思,因为它用jq_schema来指定提取路径。不是所有JSON都整段需要入库,往往只需要某些字段。比如报文日志里只需要提取messages数组中的内容:
from langchain_community.document_loaders import JSONLoader loader = JSONLoader( file_path="chat_log.json", jq_schema=".messages[]", text_content=False, )text_content=False表示按结构化对象处理而不是纯字符串拼接,更利于保留JSON结构。
网页类:WebBaseLoader / SeleniumLoader
抓取API文档、产品页面做RAG也很常见。WebBaseLoader基于BeautifulSoup,通过bs_kwargs传BeautifulSoup的解析参数。需要JavaScript渲染的页面则要用SeleniumLoader。
这个环节常见的坑是网页标题、导航、页脚等噪声全被一起抓进来,向量库里塞进去一堆无关文本。处理办法通常是两步:Loader加载后用BeautifulSoup解析并只保留正文区域文本,或者用HTMLHeaderTextSplitter先把HTML结构提取出来再切分——这个在Splitter一节会细说。
2.3 加载阶段必须处理的三个脏活
Loader返回的Document并不等于干净数据,还有几道“过滤器”要在加载后顺手做掉。
噪声与乱码过滤。纯文本文件里经常混入分页符\f、控制字符、零宽空格。这些字符在日志里毫无违和感,但对Embedding模型来说是噪声。我一般在加载后立刻做一遍字符清洗:
import re def clean_text(text: str) -> str: text = text.replace("\x00", "").replace("\f", " ") text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) text = re.sub(r"[\u200b-\u200d\uFEFF]", "", text) return text.strip() for doc in docs: doc.page_content = clean_text(doc.page_content)空内容与超小文件过滤。加载完之后经常会出现某些页提取出来是空白或者只有一行图片说明。这些Document入库等于纯噪声,检索时还会浪费预算。经验做法是设一个阈值,比如清理后小于50个字符的直接丢弃。
元数据补全与规范化。metadata里要尽早统一好字段规范。比如所有Document都要有source(文件源)、有版本号、有更新时间。而且元数据里的source不要只存“docs/report.pdf”,尽量带上能对应到原始文件的绝对路径或唯一标识——后续做引用溯源、做父子切分回查时,都会依赖这个字段。
3. Text Splitter深度拆解:切法比模型还重要
3.1 为什么必须切分:三个绕不开的限制
第一是模型限制。Embedding模型有输入上限,比如OpenAI的text-embedding-3-small上限8191个token。直接整篇文档拿去Embedding,超长的只能截断或多段处理,效果不可控。第二是检索粒度。向量检索本质是在找“相似片段”,如果整个文档是一个向量,用户问“其中某个小节的具体操作”,检索出来的方向是整篇文档,粒度太粗,完全失去意义。第三是输出可用性。大模型生成时如果拿一段3000字的检索内容,其中有效答案只有100字,既浪费上下文窗口又容易把模型带偏,回答质量显著下降。
所以切分不是“因为长度超了所以切一下”,而是在检索精度和语义完整度之间找平衡点。理解了这点,切分参数的调整才不会变成玄学。
3.2 两个核心参数:chunk_size和chunk_overlap
所有Splitter都要理解这两个参数,先看官方版定义加我的注释:
chunk_size:每个chunk的目标大小。注意这个“大小”是由length_function返回的数值决定的,默认是字符数,可以换成token数。chunk_overlap:相邻chunk之间重叠的部分。作用是把前一个chunk末尾的上下文“传递”到下一个chunk开头,避免文本在切分点被生硬截断而丢失上文信息。
chunk_overlap为什么必要?用小说分段来类比:一段话结尾说“但是他没有同意”,如果不重叠,这句话被切在结尾,下一个chunk开头直接换了个新场景,检索时只召回后一个chunk的模型完全不知道“他没同意”是谁对什么事没同意。有了overlap,后一个chunk的开头会带上“但是他没有同意”这句前文,上下文就保住了。
3.3 主流分割策略逐个拆解
CharacterTextSplitter:纯按字符数切
最朴素的方案,从文档开头数到chunk_size个字符就切一刀。没有分隔符感知,不能保证切在句子边界上。实际项目中很少直接用,主要用于自定义分割逻辑时的基底实现。我的评价是:能用,但不建议做为主方案。
RecursiveCharacterTextSplitter:所有项目的最佳起点
这是LangChain官方推荐的默认分割器,也是我目前几乎每个RAG项目都会用的主力。它的核心逻辑是递归降级切分:内置一个有序分隔符列表,默认是["\n\n", "\n", " ", ""]。切分时,先尝试用段落边界(两个换行符)切,如果切出来的块还太大,就在块内部继续用下一个分隔符切,直到每个块都不超过chunk_size。
这个设计最大的好处是优先保证语义完整性——能用段落边界切就不用句子边界切,能用句子边界就不强拆单词。实际调参中最关键的就是调整这个分隔符列表,让它贴合你的文档特征:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], keep_separator=True, is_separator_regex=False, )我特意把中文的句末标点加进了分隔符列表。默认的英文分隔符在中文语料上表现还行,但加了。!?;之后,能明显减少把一句话硬切成两半的情况。keep_separator=True表示保留分隔符在chunk结尾,这样“XXX说”这类句尾不会因为丢分隔符导致语义悬空。对于中文文档,这是最值得微调的一个点。
TokenTextSplitter:真正按LLM的视角切
按字符数切和按token数切是两回事。英文中一个token约等于0.75~1个单词,中文一个汉字在某些tokenizer下可能对应到1.5甚至2个token。如果严格要求每个chunk不超过Embedding模型的token上限,就不能靠字符数估算,而要用真实tokenizer统计。
LangChain早期提供TokenTextSplitter,它底层用的是tokenizer库按token边界切分。更常见的推荐做法是用RecursiveCharacterTextSplitter配合tiktoken计算长度:
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings import tiktoken def tiktoken_len(text: str) -> int: encoding = tiktoken.get_encoding("cl100k_base") return len(encoding.encode(text)) text_splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=100, length_function=tiktoken_len, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], )或者直接用from_tiktoken_encoder:
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( chunk_size=800, chunk_overlap=100, )这个方法会先用tiktoken对文本做编码统计,再在末尾按token边界补切一刀。实测需要注意:from_tiktoken_encoder在内部为了性能,先用字符近似估算,只有超长chunk才会真正按token精细切分,所以对于大多数中长文档结果够用,但对极长段落可能有偏差。要求严格时,自己写length_function更可靠。
MarkdownHeaderTextSplitter:保留结构的进阶分割
处理Markdown和HTML文档时,普通Splitter会把标题和正文混在一起切,导致结构信息丢失。MarkdownHeaderTextSplitter先按标题层级切分,再把标题路径写进metadata,最终切出的每个chunk都自带上下文路径:
from langchain_text_splitters import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "H1"), ("##", "H2"), ("###", "H3"), ] splitter = MarkdownHeaderTextSplitter(headers_to_split_on) docs = splitter.split_text(markdown_text) # 例如 metadata 会是 {"H1": "安装指南", "H2": "环境准备"}这种做法对维护类文档、API文档特别有用。检索“某函数参数说明”时,召回结果不仅包含该函数段落,metadata里的标题路径还能直接用作引用来源。
SemanticChunker:按语义边界切分
SemanticChunker从langchain_experimental.text_splitter导入,思路完全不同:它先计算相邻句子之间的Embedding相似度,相似度发生明显跳变的位置视为语义边界,在边界处切分。好处是切出来的每个chunk内部语义高度内聚,很适合无固定结构的对话记录、新闻正文等文本;代价是切分时需要调用Embedding模型,耗时和成本都高不少。
from langchain_experimental.text_splitter import SemanticChunker from langchain_openai import OpenAIEmbeddings splitter = SemanticChunker( embeddings=OpenAIEmbeddings(), breakpoint_threshold_type="percentile", breakpoint_threshold_amount=80, )这是我现在处理熟语料(比如专家访谈、会议纪要)时经常用的主力方案,实测召回质量比固定切分高不少,但全量文档跑一遍会比较贵。如果文档数量上百万,建议先粗切再做语义切分,或者只在检索命中粗结果后再做局部精切。
3.4 chunk_size到底选多少,一个可复用的决策框架
业界流传的推荐多是“500左右”,但这个数字不能盲抄。我提供一个更实用的决策流程:先想清楚检索单位是什么,再倒推chunk_size。
- 问答型知识库,问题通常聚焦在一个操作步骤或一个定义上,每段答案通常在200~400 token,推荐
chunk_size=300~400,overlap取50~80。 - 长文档综合问答,比如让模型阅读整本手册后总结,问题可能发散,需要chunk持有足够的上下文才能覆盖答案分布,推荐
chunk_size=500~800,overlap取100~150。 - 法律法规、合同等严谨文本,每一条款语义独立,尽量按下文条款的边界切,而不是按固定长度切,可以用
separators多层级列表优先在条款编号处切分。
这个框架的核心思路是:chunk_size不是“文本长度”的设定,而是“最小可回答语义单位”的近似表达。如果你不知道自己该用多大,先按500跑一版,打印前10个chunk肉眼检查一遍,看到大量句子被截断就调大,看到chunk语义混杂就调小——肉眼检查胜过一次玄学调参。
4. 完整实操:从一个多格式文档目录到可入库chunk
4.1 需求与场景假设
假设现在要做一个内部知识库,数据是一堆技术文档,包括PDF手册、Markdown说明、CSV配置表。目标是加载、清洗、切分、校验后交给下游Embedding入库。我直接给出一个能跑的Python流水线,并逐段注释关键点。
from pathlib import Path import hashlib, re from langchain_community.document_loaders import ( DirectoryLoader, TextLoader, PyPDFLoader, CSVLoader, ) from langchain_text_splitters import RecursiveCharacterTextSplitter def clean_text(text: str) -> str: text = text.replace("\x00", "").replace("\f", " ") text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) text = re.sub(r"[\u200b-\u200d\uFEFF]", "", text) return text.strip() def make_loader(file_path: str): suffix = Path(file_path).suffix.lower() if suffix == ".pdf": return PyPDFLoader(file_path) if suffix == ".md": return TextLoader(file_path, encoding="utf-8") if suffix == ".csv": return CSVLoader(file_path) raise NotImplementedError(f"Unsupported: {suffix}") # 1. 遍历目录,逐个加载 base_dir = Path("docs/") all_docs = [] for file_path in sorted(base_dir.rglob("*")): if not file_path.is_file(): continue if file_path.suffix.lower() not in {".pdf", ".md", ".csv"}: continue loader = make_loader(str(file_path)) loaded = loader.load() for doc in loaded: doc.page_content = clean_text(doc.page_content) if len(doc.page_content) < 50: continue # 2. 补全统一元数据,并生成内容hash doc.metadata["source_path"] = str(file_path) doc.metadata["file_name"] = file_path.name doc.metadata["content_hash"] = hashlib.md5( doc.page_content.encode("utf-8") ).hexdigest() all_docs.append(doc) print(f"loaded: {len(all_docs)} documents") # 3. 切分 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], keep_separator=True, ) chunks = text_splitter.split_documents(all_docs) print(f"chunks: {len(chunks)}") # 4. 校验:检查chunk长度分布 from collections import Counter lengths = [len(c.page_content) for c in chunks] print("min:", min(lengths), "max:", max(lengths)) # 5. 给chunk补充可溯源字段 for idx, chunk in enumerate(chunks): chunk.metadata["chunk_id"] = f"{chunk.metadata.get('file_name', 'doc')}_{idx}" chunk.metadata["chunk_index"] = idx这段代码的核心逻辑很清楚:加载 → 清洗过滤 → 补元数据 → 切分 → 校验。有一个细节很多人会忽略:切分之后,原本一页PDF文本可能被拆成多个chunk,它们共享同一个source_path,但彼此之间在正文上并不连续。为了支持后续的父子检索和引用溯源,必须给每个chunk一个稳定ID,并且最好保留它在原始文档中的序号或标题路径。高版本的LangChain里Document有id字段,直接用起来更顺手。
4.2 切分结果的眼检方法
代码跑通只是第一步,切分质量必须肉眼验收。我习惯随机抽10个chunk,打印前几行,检查三个点:开头是不是一句完整的话?结尾是不是被截断的半句话?chunk之间overlap是否有明显重复大量内容?
for i in range(5): print("=" * 60) print(chunks[i].page_content[:300]) print("meta:", chunks[i].metadata)如果开头频繁出现“然后”“但是”“此外”这类承前启后的词,说明前一chunk的上下文没有充分带过来,overlap可以适当调大。如果结尾大量出现分号、逗号而不见句号,说明separators里句子边界符的优先级不够,应该把。!?提到换行符之前试一次。
这套眼检流程虽然土,但比依赖任何自动化指标都直观。我做了这么多个项目,唯一真正让我避免“上线后召回率翻车”的验收环节,就是这一步。
4.3 切分结果如何安全交给下游
切分完还不算完。向量化入库之前,有两点值得做好:去重和流式控制。
去重方面,同一份文件可能被上传两次,或者不同渠道的来源内容相同。用content_hash做一次集合过滤,能省不少向量存储成本:
seen = set() deduped = [] for chunk in chunks: if chunk.metadata["content_hash"] in seen: continue seen.add(chunk.metadata["content_hash"]) deduped.append(chunk)大目录场景下注意控制批量大小。一次性把所有chunk塞进内存再调用Embedding接口,很容易内存爆掉或触发API限流。实践上可以按batch_size=64或128分批处理,Embedding一批入库一批。
5. 常见问题与排查技巧实录
5.1 PDF表格提取后乱成一段话
表格是PDF提取的大坑。直接跑PyPDFLoader,表格内容可能会被按行打散、列丢失。排查思路:先用PDFPlumberLoader看看解析出的文本是否保留了行列分隔。如果效果还不够,建议把PDF表格用工具转成Markdown表格再入库——让LLM或PDF转工具先把表格语义结构重建好,再做切分。不要指望通用Loader能完美保住表格结构,提前接受这个现实,方案要按此设计。
5.2 编码乱码:GBK文件和UTF-8文件混在同一个目录
这个我遇到过很多次。同一个知识库里Windows导出的CSV用GBK,网上爬的网页用UTF-8。最省心的做法是统一转码:加载时指定编码,加载后立刻encode("utf-8", errors="ignore").decode("utf-8")清洗。也可以直接用第三方库chardet批量检测编码,但检测本身也有出错概率,我的建议是优先从源头统一编码,而不是在运行时靠猜。
5.3 chunk超过Embedding模型上限,API直接报错
常见的错误类似“text length exceeds limit: 12345”,原因之一是chunk_size设得过大,以及length_function用的是字符数而不是token数。一个10000字符的chunk在中文场景下可能远超8000 token。解决方法不是单纯把chunk_size调小,而是改length_function为tiktoken真实统计,并用chunk_overlap保证边界处不丢上下文。
5.4 切分把代码块和表格切断了
Markdown里嵌着多行代码块,普通Splitter按行切分,会把一段完整函数切碎。处理方案有两种。一是改用结构感知Splitter:如果文档是Markdown结构,考虑RecursiveCharacterTextSplitter配合separators=["\n\n", "\n```", "```", "\n", " ", ""],让代码块先作为整体保留。二是用CodeSplitter,它专为代码设计,能按函数、类等AST级别切分,适合代码文档独立建设的场景。
5.5 检索召回的内容总觉得“断”
用户反馈“答案不完整”“上下文不够”,先别调检索策略,回头看一眼chunk。最常见原因是chunk_size太小,以及overlap不够导致边界上下文丢失。我经历过一次:知识库问答总是漏掉前置条件,排查后发现是把400 token的chunk切太小,每块只剩100多token,背景说明全被切掉。把chunk_size调回400后,召回质量立竿见影。
5.6 overlap设置过大导致重复内容刷屏
overlap可不是越大越好。如果设成chunk_size的50%以上,相邻chunk会有一半内容重复,检索时同一个文档被重复召回的概率大增,既占用上下文又让答案显得啰嗦。经验值控制在10%~20%比较合理。如果你的文档结构特别规整(如硬换行的剧本、逐条列出的条款),甚至可以把overlap压到10%以下,避免无谓冗余。
5.7 扫描版PDF没有任何文本内容
这不是Loader的问题。加载后page_content为空或全是乱码,先确认是否扫描版——打开PDF,如果看不出任何可选择复制的文字,它就是图片。此时需要先过OCR,把图片转成文本再走Loader。方案上可以接OCR服务,也可以离线模型,但记住一点:OCR之后的文本质量参差不齐,一定要人工抽检,识别错字会让RAG检索到一堆错误内容,比不召回还糟。
6. 一点个人实战体会
数据预处理这一关,我前前后后反复调整过很多轮,最想总结成一句经验:RAG项目的前70%工作量,几乎都花在让文本变得干净、结构清晰、适合检索上。模型选型和向量库迁移都是锦上添花,真正决定系统下限的,永远是“文档加载得干不干净、文本切得合不合理”。
我自己的习惯是,每接一个新领域的数据,先用一两个样本文件跑完整条流水线,打印加载结果和切分结果,肉眼扫一遍。宁可在这个阶段多花一晚上,也不要等向量库几千条数据全灌进去了再返工。数据预处理这事没有银弹,但只要你理解了Loader在解决“格式统一”问题、Splitter在解决“检索粒度”问题,遇到任何诡异文档,都能迅速定位是哪个环节出了问题。
最后再分享一个我用了很久的小技巧:切分参数全定下来之后,别急着全量跑,先跑一个子目录,把切分结果导出成JSON文件看一遍,再检查一次是否有半句话、乱码、重复块。这一步做到位,后面向量化、检索、生成的调优才会真正有意义。祝你少踩几个我踩过的坑。