1. 表格数据进 RAG,为什么切片就废了
先聊一个我踩过好几次的坑。做 RAG(检索增强生成)知识库,大家第一步通常都是拿 PDF、Markdown 或者网页文档开刀,文本一切、嵌入、建索引,流程很顺。但等到想把 CSV、Excel 或者数据库里的结构化数据也塞进知识库时,你会发现之前的"通用分块"招数完全失灵——不是检索效果差,而是检索回来的内容根本没法看。
举个例子,你有一张用户订单表,里面是"订单号、用户名、商品名称、金额、下单时间"。如果你按文本的方式把它切成一个个固定长度的小块,一个 chunk 里可能只有半行记录,或者把第一行的后半截和第二行的前半截裹在一起。用户问"上个月金额最高的前五笔订单有哪些",检索系统召回的是几个残缺的片段,大模型看了也拼不出一张完整的表,只能硬编一个答案给你。
这事的根源在于:表格数据的语义是躺在"表头 + 行 + 列 + 单元格"的结构关系里的,不像连续文本那样可以顺着读。顶多靠上下句承接住意思。把表格当作纯文本分块,等于先把数据的骨架拆散了,再指望模型能还原出一张完整的表,这从原理上就走不通。
所以做表格与数据库导入时,真正要解决的不是"怎么加载文件",而是"怎么把结构信息无损地转递给嵌入模型和 LLM"。这一篇我就拿 CSV、Excel 与 LlamaHub 连库实战来拆解这件事。文章不会讲太多名词,重点是可落地的处理流程和我在项目里反复调过的参数、踩过的坑,适合正在搭 RAG 知识库、尤其是知识库里需要包含业务表格数据的开发者参考。
2. CSV 导入的隐藏成本:编码、逗号与行基块设计
CSV 看着最简单,但真做进 RAG 管道的细节比想象中多。我在生产环境里换过三种导入方式,最后稳定下来的方案是从"纯文本解析"升级到"结构感知解析"。
2.1 pandas 读取时的两个约定:UTF-8 与 dtype
先用 pandas 读取 CSV,这是最快的方式。但两个地方必须提前处理,否则后面全是坑。
第一个是编码。国内业务系统导出的 CSV,十有七八是 GBK 或 GB18030 编码,直接pd.read_csv("data.csv")大概率给你抛一个UnicodeDecodeError。稳妥的做法是先用二进制方式探测编码:
import chardet import pandas as pd with open("data.csv", "rb") as f: raw = f.read(10000) result = chardet.detect(raw) df = pd.read_csv("data.csv", encoding=result["encoding"], dtype=str)第二处容易掉链子的是数字列。pandas 默认会把"订单号 001"这种字符串读成整数 1,等你要拿原始值去 match 数据库里的记录时,对不上,排查半天才发现是类型转换的问题。所以一律用dtype=str读进来,把格式化的权利留给自己,等生成文本块时再按需转换。
还有一个经常被忽略的:CSV 单元格里的内容可能自带逗号和换行。不要自己用split(",")去解析,会直接拆错字段,pandas 和 Python 内置的csv模块都处理过引号转义和换行,别重复造轮子。
2.2 行基块(row-based chunk)的正确构造方式
读完 DataFrame 后,关键问题是:怎么把它变成嵌入友好的文档块?
我试过整张表塞进一个 Document、一行塞一个 Document、几行合并塞一个 Document,三种方式。实际验证下来:
- 整表一个 Document 太粗糙,表一大检索命中率下降得很明显,一次召回你拿到的是整张两万行的表的文本,上下文塞不下;
- 一行一个 Document 太琐碎,既没有上下文,又容易和其他行语义重合,同一个字段值在每行都会出现,造成向量空间里大量冗余;
- 按业务行数组合(比如 10~20 行一个块)相对中庸,检索时能召回一小段连续记录,LLM 能从中归纳出模式,又不会撑爆上下文。
我自己常用的参数是row_group_size=15左右,同时把表头信息作为前缀写进每个块里。块内容大概长这样:
表名: user_orders 字段: 订单号, 用户名, 商品名称, 金额, 下单时间 数据: 12345, alice, 无线鼠标, 89.00, 2025-01-12 12346, bob, 机械键盘, 399.00, 2025-01-13 ...表头重复出现在每个块里,会牺牲一点嵌入的存储空间,但换来的是每个块在向量空间中自包含,检索时即使只命中一个块,LLM 也知道每个数字是什么意思。这一点在标题里写"表格"而不是"文本"时尤其关键——表头就是表格的语义锚点,拆了锚点,块内容就是无意义的数字串。
提示:如果你用的是 LlamaIndex,可以直接继承
CSVReader或PandasCSVReader然后定制row_group_size。这一步不要偷懒用默认的按行拆分。
2.3 空值、脏数据与类型标记的预处理
CSV 的脏数据分布比你想的严重。我在一个客户的数据文件里见过空值字段、"-"占位、全角逗号混入、金额列出现"待确认"这种文本。如果不洗数据,后面检索到的块里会有大量无意义内容,LLM 还可能把"-"当成减号去做计算。
清洗逻辑我通常写在 Document 生成前:
- 空值统一填
"N/A",不要在块里出现裸的空位; - 全角逗号、全角括号统一转半角;
- 日期统一格式为
YYYY-MM-DD,方便后续 LLM 理解,也方便如果走数据库链路时类型对齐; - 数字列保留两位小数,避免把浮点误差带进问答里;
- 关键列(订单号、用户名)如果全是
N/A,这些行直接丢弃,不要留着稀释语义空间。
这一层清洗不重,几行代码的事,但对最终问答质量的提升很直观。我第一次跑实验时偷懒跳过了清洗,用户问"退货订单里金额是 N/A 的有多少",LLM 答得很含糊,后来清洗完再检索,答案就准确很多。
3. Excel 的结构复杂度:sheet、合并单元格与公式值
处理 Excel 比 CSV 难一个数量级,难点不在读取,而在"结构扁平化"。
3.1 sheet 拆分的必要性
一个工作簿里常有多个 sheet:明细表、汇总表、参数表、说明页。如果整个工作簿只生成一个 Document,不同 sheet 的语义会互相污染。用户问"参数表里的汇率是多少",检索结果里混着明细表的一大段销售记录,模型会茫然。
我的做法是把每个 sheet 当成独立的 Document 来构建,Document 的元数据里写入{source_file: "xxx.xlsx", sheet_name: "参数表"}。好处是不仅内容分开,元数据也能帮检索做过滤——LlamaIndex 的 metadata filter 可以直接卡 sheet 维度。写入库之后,你甚至可以在 query 转译时附加filter="sheet_name == '参数表'",这种对检索的精确控制是整文件一股脑灌进去永远做不到的。
3.2 合并单元格与公式单元格
pandas 的read_excel()读合并单元格时,只有左上角有值,右边和下边的单元格是 NaN。这在文本场景无所谓,但在表格问答里就是信息丢失。比如一个跨五行的表头"季度销售数据",只在第一行有值,后面四行全是 NaN,如果不对齐,语义就断了。
处理办法有两条路:
一是直接用openpyxl读merged_cells属性,把合并区域的值广播填充到所有单元格:
from openpyxl import load_workbook wb = load_workbook("sales.xlsx") ws = wb["Sheet1"] for merge in ws.merged_cells.ranges: min_col = merge.min_col min_row = merge.min_row value = ws.cell(min_row, min_col).value for row in ws.iter_rows(min_row=merge.min_row, max_row=merge.max_row, min_col=merge.min_col, max_col=merge.max_col): for cell in row: cell.value = value二是不去展开,但把合并结构写成文字描述,比如"合并单元格区域 B2:E2 的值为'年度目标'"。这种方式适合简单场景,能少写代码,但对上层解析要求高。
公式单元格是另一个隐蔽问题。openpyxl默认读公式本身,比如=SUM(C2:C10),你拿到的不是数值,而是公式字符串。如果导入 RAG 时直接用这个值,LLM 会一本正经地把"=SUM(C2:C10)"当作实际数据去理解。
解决方案是你得决定:知识库里需要的是公式,还是计算后的结果?
- 如果要保留原始业务逻辑,就把公式单独提取,作为字段说明不是数据行;
- 如果要做问答,必须读取缓存的计算值。pandas
read_excel读的是缓存值(前提是这个文件被 Excel 程序打开保存过一次,或生成工具写了缓存),但 openpyxl 默认不是。实战中我的处理是优先用 pandas 读值,用 openpyxl 读公式做校验,两边一对比就知道哪些单元格有公式,再把公式和值都写进块的元数据里。
3.3 表头层级与面向问答的文本化模板
多层表头(比如两级列名"2024年 / 一季度 / 1月"这种)在 Excel 里很常见。处理时不能简单地把表头 row 拍平,否则二维信息被降成无层级的一维字符串,检索时很难对上。
我最终用一个文本化模板来解决:
表格: 门店销售统计表 Sheet: 区域明细 表头层级: 一级: 门店 | 2024年 | 2025年 二级: 门店名 | Q1, Q2, Q3, Q4 | Q1, Q2, Q3, Q4 数据行: 华东一店, 120, 140, 150, 160, 110, 130, 145, 155这里为了让 LLM 知道"140 到底是哪个指标",我会把扁平表头展开成带前缀的列名,例如2024年_Q1_华东一店。这样每个单元格的语义在生成文本块时就已经锁定,LLM 不需要去猜。表格问答的准确性在很大程度上不是靠检索算法,而是靠进入向量库之前把语义显式化到每个字段名里。这一点我必须强调,因为这是我在多个项目里反复验证过的结果。
4. LlamaHub 连库实战:从建连到自定义查询语句
说完了文件类数据,再讲数据库直连。LlamaHub 上的 DatabaseReader(在某些版本里叫DatabaseReader)是一个很适合快速接入的加载器,底层用的是 SQLAlchemy,因此 MySQL、PostgreSQL、SQLite、SQL Server 都可以连。我不建议你自己写数据库连接代码,SQLAlchemy 已经帮你处理了连接池、驱动差异和事务边界这些脏活,没必要重复造轮子。
4.1 连接串与查询参数的指定方式
DatabaseReader 的用法很简单,核心就是两段配置:
- 数据库连接字符串(Database URL)
- 查询 SQL 语句
我建议你用一个独立的配置文件(比如 YAML 或者环境变量)来放连接字符串,不要硬编码在代码里。下面是一个 PostgreSQL 的示例:
from llama_index.core import Document from llama_index.readers.database import DatabaseReader reader = DatabaseReader( database="postgresql://username:password@localhost:5432/sales_db", ) documents = reader.load_data( query="SELECT order_id, user_name, product_name, amount, order_time " "FROM user_orders WHERE order_time >= '2025-01-01' " "ORDER BY order_time DESC LIMIT 5000" )这里有个重要的经验:不要一次性加载整张表。把"加载多少数据"这个决策交给查询条件,按时间范围或业务分区去拉数据,这样导入过程可以重复执行而无负担。生产环境里数据是持续增长的,全表加载除了慢,还会让向量索引里的旧数据不断膨胀。你要做的不是把数据库复制一份到向量库里,而是只把"需要被检索到的问答知识"抽出来。
4.2 把表结构元数据注入 query 的实践
直接 SELECT 原始数据然后分块,在实践里效果只能算凑合。真正让我满意的是"先取数据、再造知识块"的二段式转换。
数据库里一张表有多列,但如果按"行"直接生成文本块,和 PDF 里按行切是一样的:每行数据会包含大量相对业务问答不重要的字段,而且字段的可读性取决于列名是否足够语义化。真实业务的列名往往是a、b、c或拼音缩写,模型看到sb也不知道是"商品",看到je也不知道是"金额"。所以数据库导入一定要做一次字段重命名 + 列注释注入:
SELECT order_id AS "订单号", user_name AS "用户名", product_name AS "商品名称", amount AS "订单金额(元)", order_time AS "下单时间" FROM user_orders WHERE ...SQL 里写别名,比在 Python 里写字典映射更直观,也更好维护。你的查询 SQL 本身就是知识库的纲目,别人接手时看到这串 SQL 就能明白这个知识库要回答什么类型的问题。
除了字段语义,表间关系也要写进块里。比如一个订单表和一个用户表,你可以生成一个"外键说明"前缀块:
表名: user_orders 关联说明: user_orders.user_id -> users.user_id 用途: 查询用户订单明细,可按用户名筛选。这个说明块不参与行数据的合并,但可以作为独立的 Document 注入索引,也可以作为每条订单块的公共前缀。它的作用是让 LLM 在回答涉及关联查询的问题时知道表之间怎么 join,这是单纯的行文本永远给不了的信息。
4.3 自定义 SQL vs 全表拉取的取舍
很多人一上手习惯SELECT * FROM table,然后想"交给 RAG 自己去理解"。我实测下来这不是个好主意:
- 大表全量拉取会让 Document 数量爆炸,向量索引的构建时间和存储成本都不可控;
- 无关字段会稀释语义,检索时模型把不相干的列也当成上下文;
- 数据库列的注释和类型信息在被查询的瞬间就丢了,除非你自己额外补。
所以我定了一条原则:在 SQL 阶段就完成选择,而不是在建模阶段靠向量检索去过滤。如果你的数据形态是"业务发生变化就新增列、改状态值",那么再好的向量索引都不如把 SQL 条件写清楚。这一步不用舍不得——向量检索擅长的是语义召回,不擅长的是过滤和聚合,而 SQL 恰好是后者的标准答案。
5. 三种数据形态的选型对比与应用场景分流
到了这一步,你可能会问:文件类和数据库类,到底用哪个?我的回答是:先看你的问答场景,再定数据管道。
5.1 CSV/Excel vs 直连数据库的决策矩阵
| 维度 | CSV/Excel 静态文件 | 数据库直连 |
|---|---|---|
| 数据更新频率 | 低,适合一次性导入 | 高,适合持续同步 |
| 问题类型 | 偏"这张表里有什么" | 偏"按条件统计、跨表关联" |
| 实现成本 | 低,代码少 | 中,需要 SQLAlchemy 配置 |
| 规模上限 | 中等(建议单表小于 5 万行) | 高,靠 SQL 分区拉取 |
| 字段语义 | 表头可直接阅读 | 列名通常是代号,需重命名 |
| 适合场景 | 报表、导出数据、离线数据集 | 业务系统、订单、用户等实时库 |
拿我做过的一个项目举例:客户这边一部分运营数据是每周从 BI 系统导出成 Excel,一部分业务主数据在 MySQL 里实时更新。前者我按 Excel 管道处理,每周重跑一次导入;后者我走 DatabaseReader 加增量时间窗口,每天凌晨拉昨天的新数据。如果你把两者混在同一套管道里,要么每周批处理跟不上实时性,要么直连查询把历史报表也重复拉一遍,两边都不讨好。
5.2 单一文件内多表工作簿的处理顺序
一个复杂工作簿里通常有多张逻辑表,上面已经讲了 sheet 拆分。但在 LlamaIndex 的索引构建中,这些 sheet 拆分后的 Document 默认会进入同一个索引。遇到这种情况,我的处理顺序是:
- 先按 sheet 拆成独立 Document;
- 每个 Document 设置元数据
sheet_name; - 构建向量索引时,在节点的元数据里保留这一字段;
- 查询时如果需要限定范围,用
MetadataFilters做过滤; - 如果要跨 sheet 汇总,就先做一次关键词检索,召回多个 sheet 的块,让 LLM 在生成阶段聚合。
这个方法能处理 90% 的多表 Excel 场景。唯一要注意的是在检索时不要把多个不相关的 sheet 混进同一个上下文窗口,否则 LLM 容易串表。测试下来召回前 3~5 个块就够了,再多反而是噪声。
5.3 什么时候该放弃向量检索、转投 Text-to-SQL
最后聊一个我反复被问到的问题:知识库里已经接入了数据库,能不能直接让 LLM 写 SQL 查数据库,别建向量索引了?
可以,但要分清场景。Text-to-SQL 的优势是精确、实时、可解释,它适合的问题是"上个月华东区销量 Top 10 的商品有哪些"。这类问题本质是结构化查询,向量索引再好也答不精确。而 RAG 的向量检索擅长的是"请解释退款政策中关于运费的规定"这种非结构化语义。
我在实际项目中常用的混合方案是:
- 先通过 Text-to-SQL 获取精确的结构化结果(比如一条订单列表);
- 再将结果转成自然语言摘要文本;
- 最后把摘要文本拼接上召回的非结构化知识块,一起交给 LLM 生成最终答案。
第一步的 SQL 生成要传表结构元数据给 LLM,包括表名、字段名、字段含义、主外键关系,这一步和上面说的 DatabaseReader 的元数据注入思路一致。第二步其实是把"数据库查询"和"RAG 检索"在答案生成层做统一。这样既保住了结构化查询的精确性,又利用了非结构化文档的语义召回能力。
这套方案在复杂查询上还有坑,比如多表 join 时 LLM 容易写错关联条件,比如字段名歧义时吞吞吐吐。但如果你只是做"连库 + 问答"的入门项目,我建议先从 DatabaseReader 开始,把行文本块跑通,再加一层 SQL 生成,不要一上来就挑战全自动 Text-to-SQL 大而全的架构。
6. 我在实际项目中总结的几条硬经验和最终建议
写到这里,我回想自己第一次做表格类 RAG 项目时的教训,有三条如果当时有人告诉我,能省一周的调参时间,在这篇里一并分享:
第一,元数据是表格 RAG 的命根子。无论是 sheet 名、列名还是表关联说明,都要在 Document 进索引之前写入元数据。不要指望嵌入模型能自己从混乱的行文本里"悟"出结构信息,语义补全的优先级永远是靠元数据而不是靠更大的模型。
第二,分块大小不是拍脑袋定的。我在 CSV 和 Excel 上都做过尝试,行数太少会导致上下文语义碎片化,行数太多会导致上下文中噪声过大,尤其当业务字段很长时。建议你拿自己数据量的 30% 做一个对比实验,分别测试 10、15、20 行每块,用 10 个你最关心的业务问题去检索引擎里跑一遍,肉眼对比召回的块内容是否贴题,再定最终参数。这比什么"默认 chunk_size=512"靠谱得多。
第三,数据库导入一定要做"只导需要的"。我见过同一个坑被踩好几次:想省事,直接SELECT *导全表,结果索引库大得慢,检索还一堆没用的列数据。后来改成按业务问题设计 SQL 视图,把字段精简到最少,效果立竿见影。知识库不是数据仓库的备份,你导进去的是"为了回答问题而准备的知识切片",不是原始数据本身。
如果你现在正卡在表格数据进 RAG 这个环节,我的最简建议是:先用 pandas 读 CSV 做行基分块跑通全流程,再处理 Excel 的 sheet 和合并单元格,最后再上 LlamaHub 连库。不要一上来直接追求 Text-to-SQL 那种复杂链路——先把最简单可靠的基础管道跑稳,再逐步加高级特性。表格数据没有银弹,每个变形都要对应一种工程处理,但每一层处理都能实打实地反映在最终问答质量上。