表格类数据做 RAG 知识库,最难的不是“能读”而是“会切”
做 RAG 项目做到第三篇,数据处理这关迟早会撞上表格。文本、PDF、Markdown 都好办,到了 CSV、Excel 甚至数据库,很多人下意识就是“读进来、切一切、灌向量库”,结果检索一跑就翻车:模型把整张五千行的表当成一段话“倒”出来,或者问到“上个月华东区的销售额”时,检索到的是一堆列名碎片,完全没法组织成答案。
我最早也踩过这个坑。把一张足球比赛统计数据表直接丢进文本加载器,出来的检索上下文里经常是“半截表头 + 几行数字”,LLM 根本不知道这些数字对应哪场比赛。后来把表格导入这套流程彻底重做了一遍,才摸清门道:表格在 RAG 里的特殊之处,在于它同时具备“行列结构”和“语义单元”两层信息,普通文本切分方式对它是残疾的。
这篇文章是“RAG 数据导入与解析全攻略”系列的第三篇,专门处理结构化数据导入。核心目标就三件事:把 CSV 文件读对、把 Excel 多 Sheet 处理干净、通过 LlamaHub 的数据库 Reader 把 MySQL/PostgreSQL 这类库连成 RAG 数据源。文章里会给出可复现的代码、参数说明和我在实际操作里踩过的坑,适合正在搭建 RAG 知识库、想把业务系统里的表格数据纳入检索范围的技术同学参考。
1. 表格不是文本:为什么 RAG 导入表格必须换个思路
1.1 一张表里藏着几万种“切法”
先想一个反直觉的问题:给你一张 100 行 × 20 列的 Excel 表,里面有多少个“语义单元”?
按单元格算,是 2000 个;按行算,是 100 个;按列算,是 20 个。但你真正要回答的业务问题,可能是“某个月某个产品线的销量趋势”——这个答案横跨多列、多行,本质上是一个“二维切片”。普通文本切分器只认一维字符流,把表格转成纯文本后,它根本不知道哪几列应该拼在一起读。
这个问题的本质,跟知识库选型很类似。这两年经常有人把 RAG、KG(知识图谱)混为一谈,实际区分很清晰:RAG 适合回答“语义相似、上下文可拼接”的问题,知识图谱适合回答“关系路径明确、需要多跳推理”的问题。表格恰好卡在两者之间——它不像纯文本那么自由,又不像图谱那样有显式的关系边。所以表格导入 RAG 的正确打开方式,不是“把表读进来”,而是“想清楚这个表会被问什么问题,再决定怎么切”。
生活里有个很好的类比:超市货架上的商品,按“品类”摆放是常规操作,但如果你要做“价格对比”,就得按“价格区间”重新归类。表格切块也是一样,切法取决于你要回答的问题形态。
1.2 连续教程的定位:这一篇要解决什么
前两篇如果按常规节奏,应该聊的是 PDF/Word 这类非结构化文本的解析与清洗。但实际项目里,表格类数据往往是知识库中“精度要求最高”的部分——业务数据、财务数字、运行指标,错一个小数点就是事故。
所以这一篇不再讲“怎么把文件读进来”这种入门操作,而是把重心放在三件事上:
- 文件级解析:CSV、Excel 各自的读取方式、参数陷阱和真实处理策略。
- 语义级切块:如何把表格行转换成适合检索和问答的自然语言块,并注入 Metadata。
- 数据源级连接:如何通过 LlamaHub 的数据库 Reader 把 MySQL/PostgreSQL 直接纳入 RAG 管线,省去手动导文件的脏活。
下面按文件类型一个一个过,每部分我都会给出可以直接跑的代码,以及在真实项目里遇到的典型问题。
2. CSV 导入实战:最容易上手,也最容易漏掉分隔符和表头
CSV 是表格数据里最“朴实”的格式,没有 Sheet、没有样式、没有公式,理论上就是把文本按逗号切分。但正因为看起来简单,很多人反而在源头就翻车——CSV 不止一种“方言”,分隔符可能是逗号、分号、制表符,编码可能是 UTF-8 也可能是 GBK,表头可能重复,文件可能巨大。
2.1 从 CSVLoader 开始:参数、解析细节、为什么分隔符和编码是必查项
LlamaIndex 里封装了不止一个 CSV 读取器。我常用的两个:
SimpleCSVReader:把整个 CSV 文件整体转成一个 Document,适合小文件快速验证。PandasCSVReader:结合 pandas 按行读取,支持把每一行转换成独立 Document,适合行级问答场景。
先看一段最小可用的 PandasCSVReader 代码:
from llama_index.core import SimpleDirectoryReader from llama_index.readers.file import PandasCSVReader reader = PandasCSVReader() docs = reader.load_data( file_path="./data/sales_records.csv", concat_rows=False, # 默认 True 会把所有行拼成一个 doc ) for doc in docs[:3]: print(doc.get_text()) print(doc.metadata) print("---")这里的concat_rows参数我建议大多数场景都设为False。它的作用很直接:True时把所有数据行拼接成一个巨型 Document,检索时容易把大量无关列一起拉进来;False时每一行独立成 Document,问题“某一行某个字段”时召回更精准。
但实际项目里我更常用 pandas 自读,再手动构造 Document,原因后面说。先看一个文件解析的真实场景:我接手过一个从业务系统导出的 CSV,用默认逗号切分后,所有字段全挤在第一列里,根本没法用。查了下源文件,发现是制表符分隔,标准库csv.reader没指定 delimiter 就直接废了。
所以,CSV 导入的第一步,永远是确认两件事:编码和分隔符。尤其是从国内业务系统导出的文件,GBK 编码的概率极高,读取时没指定encoding="gbk"就是一堆乱码。处理顺序建议是:先用chardet或file命令探测编码,再检查前 3 行的分隔符样式,最后再跑导入脚本。
2.2 行粒度切分与 Metadata 注入:让每个切块都记得自己来自哪一行
表格切块跟文本切块最大的不同在于——切出来的块必须有“身份”。文本切出来,你知道它来自哪个文档就够了;表格切出来,你需要知道它来自哪一行、哪一列、属于哪个分类,否则检索系统无法回答“这个数字是哪个月的”。
实际操作时,我通常会把每一行转成结构化的文本,同时把关键字段塞进 Metadata。看下面这段代码:
import pandas as pd df = pd.read_csv("./data/sales_records.csv", encoding="utf-8") docs = [] for idx, row in df.iterrows(): text = ( f"{row['month']} {row['region']} {row['product']} " f"销售额 {row['amount']} 元,同比增长 {row['growth_rate']}%" ) docs.append( Document( text=text, metadata={ "source_file": "sales_records.csv", "row_index": idx, "month": row["month"], "region": row["region"], "product": row["product"], }, ) )这段代码做的事很简单:把每一行格式化成一句完整的话,再把“行号、月份、区域、产品”这些字段作为 Metadata 注入。为什么必须这么做?因为 RAG 问答时,检索出的 Document 会作为上下文交给 LLM,如果 LLM 需要给出“依据来源”,Metadata 就是它唯一能引用的信息源。没有 Metadata 的表格数据,等于把答案和证据切断了联系。
Metadata 另一层价值是检索过滤。比如用户只问“华东区的数据”,如果检索阶段能直接用 Metadata 的region字段过滤,召回质量会远高于纯向量相似度匹配。
2.3 一个实际案例:football-data 数据导入的切分实践
拿热词里出现的 football-data.co.uk 的 CSV 来说。这个数据集每行代表一场足球比赛,字段有Div(联赛)、Date(日期)、HomeTeam、AwayTeam、FTHG(主队进球)、FTAG(客队进球)等。
一个最容易犯的错误是:把这 20 多个字段原样塞进向量库。结果就是,LLM 检索到一行数据时,看到的是:
E0,2023-08-12,Arsenal,Nottingham Forest,2,1,0,0,2,1,...这串字符没有任何自然语言含义,向量模型对纯数字和缩写的编码效果也极差。正确的做法是把行转换成可读的句子:
2023-08-12,英超联赛,阿森纳主场对阵诺丁汉森林,比分 2:1 获胜。然后按“球队或赛季”设置 Metadata。这样用户问“阿森纳 2023 赛季开局表现如何”时,检索到的上下文才是有意义的。这个处理逻辑也适用于所有结构化表格:把机器友好的行数据,转成人话,再交给 RAG。
2.4 防踩坑清单:编码、分隔符、表头重复、大文件分批
CSV 导入我整理了最容易踩的四个坑,按严重程度排序:
| 问题 | 典型表现 | 解法 |
|---|---|---|
| 编码不一致 | 读到乱码或报UnicodeDecodeError | 先探测编码,指定encoding读取 |
| 分隔符错误 | 所有字段挤在同一列 | 检查源数据格式,指定sep或delimiter |
| 表头重复 | 多行表头混入数据 | 读取时设置header=0,导入前做去重校验 |
| 大文件超时 | 导入耗时过长或内存溢出 | 分批读取、按 chunk 构造 Document 并渐进式写入向量库 |
关于大文件,说个具体经验:我导过一个 80 万行的销售明细 CSV,如果一次性全部转成 Document 再入库,内存直接吃满。后来改成 pandas 的chunksize=50000分批读取,每批处理完就写入向量库并释放引用。这个习惯在大 CSV 导入时几乎是必须的。
3. Excel 导入实战:多 Sheet、合并单元格与公式列的真实处理
Excel 比 CSV 麻烦一个量级。.xlsx本质上是一个 zip 包,里面装着工作表、样式、注释、公式等多层结构。直接用文本方式读 .xlsx,等于把一本带目录和索引的书当纯文本扫描,信息结构全丢了。
3.1 为什么不能直接读取 .xlsx 文件内容
有人图省事,把 .xlsx 文件路径直接传给SimpleDirectoryReader,指望它能自动识别。这个方案偶尔能跑通,但风险很大:一旦文件里含有公式、合并单元格、多 Sheet,读取结果就跟原表对不上。
正确的做法是先借助 pandas 或 openpyxl 把 Excel 解析成 DataFrame,再由你决定如何组织 Document。核心流程三步走:
- 用
pandas.read_excel()读取指定 Sheet,处理空值、合并单元格。 - 检查每一列的数据类型,尤其是日期和数字(Excel 里的日期经常被序列化成 45292 这类数字)。
- 按行或按业务自然单元构造 Document,注入 Sheet 名和行列信息作为 Metadata。
3.2 多 Sheet 策略:每个 Sheet 独立成文档,还是合并?
多 Sheet 文件几乎是 Excel 场景的标配,比如一个年度报表文件里,“Q1”“Q2”“Q3”“Q4”各占一个 Sheet。这里有两种策略,选择标准是“Sheet 之间语义是否独立、列结构是否一致”。
import pandas as pd from llama_index.core import Document file_path = "./data/annual_report.xlsx" xl = pd.ExcelFile(file_path) docs = [] for sheet_name in xl.sheet_names: df = xl.parse(sheet_name) # 如果列结构一致,可以合并后统一处理;否则独立成 Document for idx, row in df.iterrows(): text = ",".join([f"{col}:{val}" for col, val in row.items()]) docs.append( Document( text=text, metadata={"sheet": sheet_name, "row_index": idx}, ) )我自己的判断标准是:如果每个 Sheet 回答的是同一类问题(比如各季度销售排行),就合并成一个整体处理,Metadata 里区分 Sheet;如果 Sheet 之间业务差异很大(比如一个是销售明细,一个是产品目录),就分别处理并添加独立的描述性前缀,防止检索时跨 Sheet 混淆。
3.3 Excel 特有坑:合并单元格、公式缓存、下拉失效
Excel 的坑往往不在读取代码本身,而在文件数据质量的“暗伤”。这里专门说三个我实际遇到的:
合并单元格。pandas 读合并单元格时,默认只有左上角第一个单元格有值,其余全是NaN。如果不处理,切块后就是“第一行有标题,后面全是空”。解法是在读取后做前向填充:df = df.fillna(method="ffill"),让合并区域的所有行都拿到同一个值。注意填充之前先检查业务含义,别把不该填充的独立空单元格也填了。
公式缓存。用 openpyxl 读取时,如果data_only=False,纯公式单元格拿到的是一长串公式字符串,不是计算结果;如果data_only=True,又要求文件在 Excel 里至少被打开过一次,否则缓存值可能不存在。实际项目里,我一般优先用 pandas 读取(它会自动处理公式缓存),实在需要原始公式再单独走 openpyxl。
公式下拉失效。这个词条经常出现在 Excel 相关搜索里,背后的场景是:某些单元格公式没有正确计算结果,导出的数据列全为空。在 RAG 导入时,这类问题不会报错,但结果是向量库里多了一堆空值文档,检索质量被悄悄拉低。所以 Excel 导入流程里,我强烈建议加一道非空率校验——统计每列的非空值比例,低于阈值的列直接报警,人工确认后再继续。
3.4 健壮的 Excel 导入函数示例
把上面提到的处理逻辑整合成一个函数,可以直接抄走用于项目:
import pandas as pd from llama_index.core import Document def excel_to_documents(file_path, date_cols=None): xl = pd.ExcelFile(file_path) docs = [] for sheet_name in xl.sheet_names: df = xl.parse(sheet_name) # 1. 处理合并单元格带来的空值 df = df.ffill() # 2. 日期列统一转成字符串,避免序列化数字 for col in date_cols or []: if col in df.columns: df[col] = pd.to_datetime(df[col]).dt.strftime("%Y-%m-%d") # 3. 每行转成自然语言块 for idx, row in df.iterrows(): text_parts = [] for col, val in row.items(): if pd.notna(val) and str(val).strip(): text_parts.append(f"{col}:{val}") if text_parts: docs.append( Document( text=",".join(text_parts), metadata={"sheet": sheet_name, "row_index": idx}, ) ) return docs这个函数把“合并单元格填充、日期格式化、行粒度切分”都处理好了。日期列格式化这一步特别重要,Excel 内部日期是数字序列(比如 45292),如果不转,向量模型会把它当成普通数值处理,语义完全错乱。
4. LlamaHub 连库实战:把 MySQL/PostgreSQL 当 RAG 数据源
文件导入只是第一步,真正到了业务落地阶段,数据往往不在文件里,而在业务系统背后的数据库里。每次手动导 CSV 再导入 RAG,既慢又容易脏。更稳的路径是让 RAG 直接连数据库。
4.1 LlamaHub 上数据库 Reader 的选型逻辑
LlamaHub 提供了多个数据库相关的 Reader,常见的有:
DatabaseReader:通用数据库读取器,基于 SQLAlchemy,支持大多数关系型数据库。PostgresReader、MySQLReader:针对特定数据库封装。PrestoReader:用于 Presto 查询引擎。
我的选型建议是:先用DatabaseReader,除非你有明确的数据库特性依赖。原因很简单,DatabaseReader基于 SQLAlchemy,连接串写法统一,切换数据库时不需要改代码逻辑。实际项目中我也遇到过非 MySQL/PostgreSQL 的数据库(比如 SQL Server、ClickHouse),通用 Reader 的适配成本低很多。
4.2 连接串与查询配置:最小可用配置
DatabaseReader的使用非常直接,核心就两个参数:数据库连接串和要执行的 SQL 查询。
from llama_index.readers.database import DatabaseReader reader = DatabaseReader( database_url="postgresql://user:password@localhost:5432/mydb" ) docs = reader.load_data( query="SELECT order_id, customer_name, amount, order_date FROM orders WHERE order_date >= '2024-01-01'" )这里有几个容易被忽略的点:
- 连接串的格式要写对。PostgreSQL 是
postgresql://,MySQL 是mysql+pymysql://,SQL Server 是mssql+pyodbc://。漏掉驱动名会直接报找不到驱动。 load_data本质上会执行你提供的 SQL,然后把每一行转成一个 Document。如果你写了SELECT *,而表有 50 列,每个 Document 就会包含 50 个字段,向量化后的文本非常臃肿,检索质量必然受影响。- 查询语句自带过滤条件很关键。全表导入在数据量大时既慢又破坏检索精度,带上
WHERE条件按业务范围过滤,是数据库导入的第一道质量闸门。
4.3 行级切块与 SQL 查询设计:什么时候 SELECT 整表,什么时候只选需要的列
数据库导入最忌讳“一把梭”。我建议按下面的逻辑设计 SQL:
- 数据量小于 1 万行:可以查询主要列,但也不要
SELECT *,只选业务要用的字段。 - 数据量大(超过 10 万行):强烈建议用 SQL 聚合或条件过滤。比如只导入“最近三个月的数据”,或者按“重点客户”过滤,而不是全量导入。
- 需要保留行号语义:如果表没有主键或唯一标识,可以在 SQL 里用
ROW_NUMBER()加一列行号,方便后续定位来源行。
SELECT ROW_NUMBER() OVER (ORDER BY order_date) AS row_no, order_id, customer_name, amount, order_date FROM orders WHERE order_date >= '2024-01-01'实际项目中,我还用过一种“视图法”:在数据库里先把业务需要的查询逻辑写成视图,然后让DatabaseReader直接查询视图。好处是 SQL 逻辑沉淀在数据库层,RAG 管线里只保留一句简单的SELECT * FROM v_rag_orders,后续字段调整不用改代码。
另外提醒一句,数据库里的NULL 值在转文本时会被 Python 处理成None,如果不做清洗,Document 里就会混入大量 “字段名: None” 的垃圾文本。导入前用COALESCE或 pandas 侧过滤,把空值处理干净。
5. 导入后的检索质量自检:怎么证明表格数据没有“白导”
文件导入了、向量库建了、查询也能跑了,但表格数据到底有没有被正确地“送进”RAG?这一步很多人直接跳过,结果上线后才发现检索质量极差。这一章讲如何用最小的成本做质量自检。
5.1 检索失败的两类典型症状
表格数据导入后,最常见的失败症状有两类,特征很鲜明:
症状一:模型把整张表“倒”出来。用户问“上个月卖得最好的产品是什么”,检索返回的是几千行的完整表格文档,LLM 面对海量噪音,要么给出错误答案,要么干脆拒绝回答。根因是切块粒度太大,把整表当成一个 Document。
症状二:检索到的是“列碎片”。用户问“2024 年销售额”,检索结果里只有“销售额: 128000”这样的字段碎片,LLM 不知道这个数字对应的是哪个部门、哪个时间。根因是文档缺失上下文前缀或 Metadata,行与业务实体之间没有建立关联。
这两类问题靠目测很难发现,必须用“问题-文档-答案”三层验证来抓。
5.2 快速设计一个 10 条问题的验证集
验证集不需要多,10~20 条足够暴露大部分问题。设计验证集时,我通常覆盖三类问题:
| 问题类型 | 示例 | 成功标准 |
|---|---|---|
| 行查询类 | “2023年8月阿森纳主场对阵谁,比分多少” | 检索结果中包含准确行 |
| 列对比类 | “哪个区域的销售额最高” | 检索结果中包含多个区域的行,可支撑对比 |
| 聚合计算类 | “全年总销售额是多少” | 检索结果中包含足够多的明细行,可推算总和 |
跑完验证集后,记录三个指标:命中率(检索结果是否包含必要信息)、上下文准确率(检索结果中无关内容占比)、最终答案正确率(LLM 基于上下文给出的答案是否正确)。其中命中率是最先要优化的目标——如果必要的行根本搜不出来,后面做得再多都是白搭。
5.3 调优方向排序
如果验证结果不理想,按下面的优先级调优:
- Metadata 维度:检查是否注入了足够多的过滤维度(时间、分类、区域)。缺失时优先补。
- 切块粒度:当前按行切块,如果聚合类问题效果差,改成按“业务事件”聚合(比如一场比赛一个块、一张订单一个块)。
- 查询改写:向量检索前对用户问题做改写,把“上个月”转成具体月份,能显著提升命中率。
- 向量模型:最后才考虑更换 embedding 模型。表格数据对向量模型的语义理解要求高,但多数问题出在前三层。
这里我要特别强调一个经验:表格数据的检索质量,大概率出在“准备阶段”,而不是“检索阶段”。很多人一上来就换更贵的 embedding 模型,效果纹丝不动,回头检查才发现是字段文本太碎、或者没做上下文补充。先检查文档质量,再动手换模型。
5.4 我做表格导入时踩过的复盘
最后分享三个真实的教训,都是代码层面看不出、但直接烧掉大量调试时间的问题:
第一个是Metadata 没注入导致答案无法溯源。上线后业务方要求“每个答案必须能点回原始数据行”,当时只存了文本没存行号,差点推倒重来。现在写入每个 Document 时,我都会强制要求 row_index 和 source_table 两个字段。
第二个是日期字段被当作文本处理。Excel 的日期序列化成数字后,向量检索时“2024-01-01”和“45292”是完全不同的语义,导致“查询某月数据”时召回极其不稳定。这类问题靠肉眼看不见,必须在导入阶段统一格式化日期列。
第三个是数据库里的超长字段把 top-k 挤没了。某次表里有个“备注”列,每行几百字,向量化后占满了前几个相似度结果,真正要用的订单字段反而排在后面。处理方式是在 SQL 查询阶段直接排除掉分析价值低的字段,不进向量库。
表格导入这件事,说到底是“先定义任务,再决定切法”。如果你连“这张表将来会被问什么问题”都没想清楚,拿什么切块策略去导,结果都是随缘。我的建议是:动手导入前,先花半小时列出这张表要支持的 5~10 个高频问题,再让切块方案反推。这半小时,往往能省下后面几天的调优时间。