一提到给大模型做知识库,很多人马上就会想到把文档交给LlamaIndex,然后坐等机器人回答问题。可真到动手做的时候才发现,同样的资料,别人做出来的问答又准又稳,自己做的经常答非所问。差别往往不在模型,也不在向量数据库,而是在数据进模型之前的那道工序——预处理。
一、为什么说非结构化数据预处理是决定问答质量的那块基石
很多人觉得,把PDF、Word、网页这些非结构化数据扔给LlamaIndex,让它自动切一切、存一存,就能直接用了。其实没那么简单。这些原始文件里藏着大量“噪音”:页眉页脚、乱码、重复段落、图片里的文字、表格的错位……如果直接拿来构建索引,检索的时候就会把噪音也当成有效信息,最后大模型就会一本正经地胡说八道。
我在实际项目里见过很多次这种情况:同一个文档,有人用默认配置跑出来的检索结果乱七八糟,有人花了一个小时做预处理,效果立刻提升一个档次。预处理不是可有可无的一步,它直接决定了向量检索的召回质量,进而决定了大模型回答的准确度。
应用场景非常广,比如企业做知识库问答、客服系统、法律文档检索、医疗报告整理、教育课件分发等等。只要你的原始数据不是干净的纯文本,就离不开预处理。
那么,LlamaIndex里有哪些预处理动作能帮我们“洗”数据呢?下面我把自己常用的几个方法拆开讲。
二、装载数据时就要留个心眼
2.1 选对加载器,从源头减少麻烦
LlamaIndex的SimpleDirectoryReader几乎什么都能读,但它只是“能读”,并不代表“读得好”。比如一个PDF文件,如果是从Word导出的,它有文本层,读取时能直接拿到文字;但如果是扫描件或图片型PDF,读出来就是空白的,这时候就需要OCR工具先处理。
我一般会在读取前先检查一下文件结构,然后用SimpleDirectoryReader指定文件类型和递归方式。看一个完整示例:
# 技术栈:Python + LlamaIndex
from llama_index.core import SimpleDirectoryReader
# 一次性读取某个文件夹下的所有文件
documents = SimpleDirectoryReader(
input_dir="./data", # 要读取的文件夹路径
required_exts=[".pdf", ".docx", ".txt"], # 只读这些格式,避免杂七杂八的缓存文件
recursive=True # 如果子文件夹里也有资料,一并读取
).load_data()
print(f"一共加载了 {len(documents)} 个文档")
# 每个documents里的元素都有text和metadata属性
print(documents[0].text[:100]) # 看看第一个文档开头的内容
print(documents[0].metadata) # 看看自带的元数据(文件路径、文件名等)
注意,这里我故意加了required_exts,防止input_dir里混入其他临时文件,也防止程序把隐藏文件读进来。这一步看似简单,却能让后续操作干净不少。
2.2 处理不同文件格式的那些“坑”
不要以为加载器都帮你搞定了。读PDF时,如果发现文本中间夹杂着很多换行符,那很可能是PDF的排版问题。读Word文档时,表格内容有时会丢失。读网页时,导航栏和广告文字也会被一起带进来。
我遇到过最头疼的是“混合型文档”:一页里有正文、有脚注、还有图片。加载器只能拿文字,图片里的信息就丢了。这时候如果你做的是知识问答,最好提前把关键图片里的文字用OCR提取出来,再拼接到原文里。LlamaIndex本身不提供OCR,但你可以先用外部工具处理,然后把处理后的纯文本交给LlamaIndex。
三、文本切分不只是“按字数切一刀”
3.1 默认切分器的局限性
LlamaIndex默认的切分方式比较粗糙,它会按照固定长度去截断文本。如果正好在一个句子的中间截断,这个“半截话”进入向量库,检索时就会产生语义偏差。比如一句话是“公司原名不叫今天这个名字,后来才改的”,如果从中间切,后半句可能变成“今天这个名字,后来才改的”,语义就完全变了。
所以,切分不能只看长度,还要尊重语言的天然边界。最简单的优化方法是使用SentenceSplitter,它先按句子切分,再把句子拼成尽量接近chunk_size的块。看示例:
# 技术栈:Python + LlamaIndex
from llama_index.core.node_parser import SentenceSplitter
# 按句子切分,而不是单纯按字数
splitter = SentenceSplitter(
chunk_size=512, # 每个块大概512个token(注意是token不是字符)
chunk_overlap=64, # 前后重叠64个token,避免把完整语义切断
paragraph_separator="\n\n" # 以空行作为段落分隔,保留段落的独立性
)
nodes = splitter.get_nodes_from_documents(documents)
print(f"切分出了 {len(nodes)} 个节点")
# 看看第一个节点内容
print(nodes[0].text)
chunk_overlap这个参数特别重要。它让相邻块之间存在一部分重复内容,这样当一条知识横跨两个块时,至少有一个块能保留完整语义。当然,overlap也不是越大越好,太大了会引入大量重复,浪费向量数据库的存储空间,检索时还容易重复召回。
3.2 按文档结构切分,让语义更进一步
如果文档有章、节、标题,那么按照标题来切分是比按句子更聪明的做法。比如一份操作手册,每个章节讲一个独立功能。如果硬把它们混在一起切,检索“怎么重置密码”时,可能会把“怎么修改头像”的内容也召回,因为它们的文本块被切到了同一个context里。
LlamaIndex提供了HierarchicalNodeParser,它可以按层级结构生成不同粒度的节点。简单来说,它会先按大段落切,再在段落内部切小段,然后建立父子关系。这样做的好处是:检索时我们可以用小片段匹配,再用大片段作为上下文送给大模型,语义完整性和精度都能兼顾。
# 技术栈:Python + LlamaIndex
from llama_index.core.node_parser import HierarchicalNodeParser
# 定义分层切分器
splitter = HierarchicalNodeParser.from_defaults(
chunk_sizes=[1024, 256], # 第一层1024 token,第二层256 token
chunk_overlap=32
)
# 返回的是多层结构的节点
nodes = splitter.get_nodes_from_documents(documents)
分层切分不是银弹,如果文档本身没有清晰结构,效果会打折扣。但只要是规范写作的文档,它通常比“一刀切”好得多。
四、元数据是给检索器指路的明灯
4.1 添加自定义元数据
很多人在预处理时会忽略元数据,这是个大损失。元数据就像给每块文字贴的“身份证”,比如:来源文件名、作者、日期、章节标题、关键词。有了这些信息,检索时就能做精确过滤,而不只是靠向量相似度“盲猜”。
比如你有一个包含2023年和2025年两份财报的库。用户问“今年营收是多少”,如果你给文档加了日期元数据,就可以先过滤出2025年的文档,再去匹配内容。否则,检索器很可能把2023年的也拽出来,然后大模型就晕了。
看一个手工构造带元数据文档的例子:
# 技术栈:Python + LlamaIndex
from llama_index.core import Document
# 自己构造一个Document,并附带业务元数据
doc = Document(
text="公司2025年第一季度营收增长20%。",
metadata={
"title": "季度财报",
"date": "2025-04-01",
"department": "财务部"
}
)
# 后续检索时,可以通过这些元数据做过滤
print(doc.metadata)
如果是从文件加载的文档,也可以通过SimpleDirectoryReader给同一批文件统一加元数据。比如:
# 技术栈:Python + LlamaIndex
from llama_index.core import SimpleDirectoryReader
reader = SimpleDirectoryReader(input_dir="./data")
# 用一个函数给每个文档附加元数据
def add_source_tag(doc):
doc.metadata["source"] = "内部资料"
return doc
documents = reader.load_data()
documents = [add_source_tag(d) for d in documents]
4.2 元数据在检索中的实际作用
LlamaIndex的VectorStoreIndex天然支持元数据过滤。你可以在查询时传入filter_str,把范围缩小到某个日期、某个部门。这比单纯靠向量远近过滤要精准得多。元数据同时还影响切分后的节点:节点会继承文档的元数据,所以你在切分之前加好元数据,后面的每个块都会带上。
五、清洗噪声数据的实用招式
5.1 干掉页眉页脚和乱码字符
非结构化数据里最常见的噪音就是页眉页脚,比如“第 1 页”、“机密文件”、“公司内部地址”等等。这些内容几乎每页都重复,如果进入向量库,会造成两个坏处:一是检索时容易把页眉页脚当成正文;二是大量重复内容让向量索引变得臃肿。
清洗时,我用正则表达式就能解决大部分问题。下面是一个我自己常用的清洗函数,覆盖了页眉、特殊换行和不可见字符:
# 技术栈:Python + LlamaIndex(配合正则)
import re
def clean_text(raw: str):
# 去掉常见页眉页脚(比如“第 1 页”之类)
text = re.sub(r"\n\s*第\s*\d+\s*页\s*\n", "\n", raw)
# 统一换行符(把Windows和旧Mac的换行都换成\n)
text = text.replace("\r\n", "\n").replace("\r", "\n")
# 去掉多余的空行(连续两个以上换成两个)
text = re.sub(r"\n{3,}", "\n\n", text)
# 去掉不可见控制字符(如\uf0b2、\u200b等)
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
# 把全角空格变成半角并去掉首尾空格
text = text.replace(" ", " ").strip()
return text
# 假设从PDF里读出来的文本
raw = "\n第 1 页\n公司简介\n\n\n我们是一家做AI的公司。\r\n"
print(clean_text(raw))
清洗这步,建议放在切分之前。清洗完再切,切出来的块才干净。如果你用SimpleDirectoryReader加载文档,也可以先读取原始文本,遍历每个doc执行clean_text,再交给切分器。
5.2 处理表格和复杂结构
表格是预处理里的老大难。直接把表格转成纯文本,行列关系会丢失,语义变得支离破碎。我的做法是:尽量把每个表格转成“键值对”或“列表”形式的描述文本。比如一个三列五行的表,可以转成“某某指标在2024年为100,2025年为120”这样的自然语言。这样向量检索能更好地理解。
当然,LlamaIndex也提供了一些PDF表格解析的扩展,但如果你用的是通用型文档,自己写一个简单的转换脚本往往更可控。核心思想是:不要丢失表格里的逻辑关系,先转换成有意义的描述,再进入LlamaIndex。
六、进阶优化:用Embedding模型给文本“体检”
6.1 检查向量距离,发现坏块
预处理完之后,构建索引之前,可以先做一次“体检”。把切好的文本块用Embedding模型转成向量,然后计算它们之间的相似度。如果发现两个块相似度出奇地高,那很可能就是重复内容,或者清洗不干净导致同一个信息被切进了多个块。
LlamaIndex的VectorStoreIndex构建时会自动嵌入,但我们也可以事先检查。下面这个示例是用LlamaIndex构建索引后,通过检索来观察是否有重复或低质量块:
# 技术栈:Python + LlamaIndex
from llama_index.core import VectorStoreIndex
# 先构建索引
index = VectorStoreIndex.from_documents(documents)
# 用查询获取高相关节点
retriever = index.as_retriever(similarity_top_k=5)
nodes = retriever.retrieve("公司主营业务是什么?")
# 手动检查是否有高度相似的重复块
seen = set()
for n in nodes:
# 取前50个字符作为指纹
text_hash = hash(n.text[:50])
if text_hash in seen:
print("发现疑似重复块:", n.text[:80])
else:
seen.add(text_hash)
这个示例不算完美,但能给你一个直觉:好的预处理后,检索出的前几个块应该彼此有区分度,而不是高度雷同。如果全是雷同,就说明切分或清洗还有改进空间。
6.2 用Embedding模型做主动去重
更主动的做法是,在构建索引前,对文本块两两计算余弦相似度,把相似度超过0.95的块只保留一个。不过全量两两计算在大数据量下很贵,通常我会先按长度、首尾字符做预筛选,只有相似度高的候选对才去计算。
另一个思路是使用LlamaIndex的IngestionPipeline,它可以把“加载-清洗-切分-嵌入-去重”串成一条流水线。这样代码结构清晰,便于维护。但因为这里是示例,我就不展开太多,你只要理解核心是:在数据进入向量库之前,反复检查“有没有重复”、“有没有噪音”、“有没有切碎”,这比事后调整提示词有效得多。
七、一些需要注意的坑
预处理优化虽然有效,但也不是参数调得越大越好。这里列几个容易踩的坑:
- chunk_size过小:比如128 token,可能连一个完整的观点都装不下,检索时语义不足。
- chunk_overlap过大:会让相邻块重复太多,检索结果冗余,回答时上下文占满重复内容。
- 元数据污染:如果你给文档加的元数据本身含有噪声,比如自动日期字段是读取时间而不是文档时间,那过滤时会误导检索。
- 清洗过猛:正则表达式写得不严谨,可能会把正文里的重要数字、符号也删掉。建议清洗后人工抽查一批样本。
- 多语言混合:如果你同时处理中文和英文,切分器需要额外注意。
SentenceSplitter默认可能按英文标点切分,中文标点处理不好,建议自己传入句子分割函数。 - 忽略表格结构:直接把表格转成纯文本,会让竖排关系解体,问答时模型容易看错数。
八、总结:预处理决定RAG的天花板
LlamaIndex本身是个好工具,它封装了加载、切分、嵌入、检索整条链路。但工具越强,越考验你对数据的理解。非结构化数据预处理不是一道“能跑就行”的工序,而是一道需要反复打磨的质量关卡。
我从实际经验里得到的最大体会是:预处理做得好,后面省心一百倍。你不需要追求一步到位,但至少要做到“源头干净、切分合理、元数据可依赖、过滤有效”。这样,当你遇到检索结果不理想时,第一反应不是去调大模型提示词,而是回头检查预处理流程。
希望这篇文章能让你对LlamaIndex的数据预处理多一份重视。下次再遇到问答效果不好,先别急着重写prompt,打开你的原始文件,看看是不是该洗洗数据了。
评论
围绕“LlamaIndex非结构化数据预处理的质量优化方法”参与讨论