
表格类数据进 RAG我一直觉得是被低估的硬骨头。文本类文档大家已经玩得比较顺了PDF、Word、Markdown 各有各的解析套路但一换成 CSV、Excel、数据库表很多人就卡住了直接整表转文本丢进向量库检索效果稀碎每行单独转成 Document又不知道元数据怎么挂才合理真要连数据库连接串、查询语句、增量同步又是一堆新问题。这篇是 RAG 数据导入与解析系列的第三篇专门把表格和数据库这条路走一遍从 CSV 到 Excel 再到 LlamaHub 连库实战每一步我都会给出可复现的代码和实际踩坑记录。先说清楚这篇适合谁你已经在用 LlamaIndex 或类似框架处理过文本数据想把手里的结构化数据订单表、配置表、产品清单真正接进 RAG或者你还没动手但对「表格数据到底该怎么进知识库」这件事没想明白想先看一条完整的落地方案。这篇不堆概念全是实操。1. 表格数据在 RAG 里的特殊位置为什么不能照抄文档解析的老路子很多人拿到表格数据后的第一反应是跟 PDF 一样读进来切块丢进索引完事。这个思路对文本没问题但对表格几乎一定会翻车。想明白为什么是这篇所有操作的基础。1.1 二维数据与「一刀切」切块的冲突文本是线性的段落和段落之间有天然的顺序关系所以按固定窗口切块、加一点重叠语义损失通常可接受。但表格是二维的横向是字段列名纵向是记录行每一格的语义价值高度依赖它的表头。如果你把整张表转成一段长文本再按 token 切常见的结果有两种。第一种情况一张 50 列、2000 行的表文本化之后可能上万 token固定窗口切出来有的 chunk 会截断前几列有的 chunk 会把同一行数据拆到两个 chunk 里。检索的时候你命中了一个 chunk但关键的数值字段在另一个 chunk 里模型根本拿不到完整记录。第二种情况更隐蔽某些表的列名本身没有语义比如col1、col2、item_code、region_id。切块后这些 chunk 里全是孤立数值embedding 模型对这些短数字片段的编码效果非常差检索召回基本靠运气。所以处理表格数据的第一原则是不要默认走文本切块 pipeline先看表的形态再决定每一行、每一块到底要以什么粒度进入索引。1.2 记录型与分析型先分清你手头是什么表我习惯把表格先分成两类处理逻辑完全不同。记录型表格典型是业务流水、订单明细、用户列表每一行都是独立的、完整的事实。这种表最适合的行粒度是「一行 一个 Document」因为一行内的字段组合起来构成完整语义行与行之间没有强上下文依赖。检索场景通常是「查某个特定条件下的记录」比如某订单金额、某客户所在地区。分析型表格典型是统计报表、汇总表、指标看板行与行、列与列之间存在维度关系某一格单独拿出来没意义必须依赖表格整体结构。比如「华东区一季度销售额」它的上下文是行表头和列表头的组合。这种表直接按行拆会丢失结构比较合理的做法是先做「表格摘要 局部摘录」或者把表按业务语义块拆分再把每个块转成自然语言描述而不是单纯贴数值。这两种表如果混在一起统一处理后面召回测试会很难受因为你对「正确结果」的判断标准都不一样。1.3 从「最终要回答什么问题」倒推导入方案这是我想强调的习惯动手导入之前先列出业务侧真实会问的问题。我做某个项目时对方列了一堆表要导入我让他们先给我十个问题。结果发现他们关心的几乎都是「某个商品最近 30 天在华南区的销量」「哪几个订单超过万元并且还没发货」这类带条件的查询。这些问题直接决定了三件事一是必须保留哪些字段作为过滤条件二是哪些字段应该写进正文文本三是每行文本应该如何组织语言。反过来看如果一开始就把所有表不分青红皂白转文本入库检索会把大量无关行捞回来query engine 再聪明也被噪声淹没。这个环节不写代码但我觉得它比代码更重要。表格数据进 RAG本质上不是「把数据塞进去」而是「为特定问题设计一种可检索的文本视图」。2. CSV 导入实战不止 read_csv 一下就完事CSV 看起来最简单其实最容易出问题。我见过太多「CSV 文件插入向量库之后一问三不知」的情况原因大多不在向量库而在 CSV 导入那一步的数据质量和行文本组织。2.1 数据体检编码、分隔符、空值的那些事CSV 作为纯文本格式第一关就是编码。国内环境中 GBK 编码的 CSV 仍然大量存在Excel 导出的老版本 CSV 甚至默认 ANSI。如果你用 pandas 直接读遇到 GBK 文件会直接抛 UnicodeDecodeError。处理方式很简单import pandas as pd # 先探测编码再读取 def detect_encoding(file_path): import chardet with open(file_path, rb) as f: raw f.read(100000) return chardet.detect(raw).get(encoding, utf-8) enc detect_encoding(sales.csv) df pd.read_csv(sales.csv, encodingenc)分隔符也值得确认一下。部分业务系统导出的 CSV 不是逗号分隔而是|或\t还有的企业数据里字段本身含逗号但没有按 CSV 标准加引号。这个不提前检查后面整行解析错位导入的文本全是乱的而且很难发现。建议读取前先pd.read_csv一个 5 行的样本打印出来看一眼。空值处理更是必做项。如果某一行关键字段是 NaN你直接str(row)会把nan写进文本检索时如果用户问「哪些订单没填写客户名」模型会被这些nan误导。导入前必须明确空值策略要么丢弃该行要么用「未知」等有效文本占位。2.2 每行转 Document还是整表转大文本我在 1.1 里说了记录型表按行转 Document。但这里有个容易忽略的细节Document的 text 字段不能是「列名: 值」的机械拼接而是要尽量转成一句完整自然语言。举个例子下面这样的原始行order_idproductregionamountstatusA1001无线耳机华东1280已发货机械拼接的结果是order_id: A1001, product: 无线耳机, region: 华东, amount: 1280, status: 已发货。检索「华东区发了多少钱的货」时这句文本的语义密度很低因为 embedding 模型对这种「字段名短值」结构的编码并不好。我推荐做一次行级文本化def row_to_text(row, table_desc订单记录): parts [] if order_id in row and pd.notna(row[order_id]): parts.append(f订单编号为{row[order_id]}) if product in row and pd.notna(row[product]): parts.append(f商品为{row[product]}) if region in row and pd.notna(row[region]): parts.append(f销售区域为{row[region]}) if amount in row and pd.notna(row[amount]): parts.append(f金额为{row[amount]}元) if status in row and pd.notna(row[status]): parts.append(f状态为{row[status]}) return f这是一条{table_desc}{.join(parts)}。这样转出来的是自然语言短句embedding 检索的命中率明显提升。这个步骤多花不了多少时间但对结果是决定性的。我在多个项目里对比过机械拼接检索 top-5 命中正确行概率大概 40%自然语言化之后能到 75% 以上。2.3 一套可直接用的 CSV 自定义导入模板LlamaHub 上其实有现成的 CSVReader但我很少直接用原因是它对 CSV 的假设太理想了默认 UTF-8、默认逗号分隔、没有空值处理、按整个文件内容作为一个大 Document 处理。对干净的小文件它很方便但对真实业务数据不够用。我更倾向于用 pandas 清洗 自定义 Document 构建import pandas as pd from llama_index.core.schema import Document def load_csv_as_documents( file_path, table_desc数据表, encodingNone, sep,, drop_emptyTrue, filter_empty_cols[order_id, amount], ): if encoding is None: encoding detect_encoding(file_path) df pd.read_csv(file_path, encodingencoding, sepsep, dtypestr) # 空值处理 df df.dropna(howall) # 全空行直接丢 if drop_empty and filter_empty_cols: df df.dropna(subsetfilter_empty_cols) # 遍历每一行转自然语言 Document documents [] for idx, raw_row in df.iterrows(): row raw_row.dropna() # 去掉 NaN 字段避免文本里出现 nan text row_to_text(row, table_desc) documents.append( Document( texttext, metadata{ source: file_path, table: table_desc, row_index: idx, }, ) ) return documents注意我把dtypestr强制全部转字符串避免 ID 变成科学计数法或日期被 pandas 改格式。然后空值字段直接不写进文本只保留有效字段。这里有个 metadata 设计细节想多说一句row_index一定要留。后面如果发现某条检索结果内容有问题你能直接通过 row_index 回原表定位定位速度比全文搜原文件快得多。数据量一大这个习惯能救你很多次。3. Excel 导入实战多 Sheet、合并单元格与公式缓存的坑Excel 比 CSV 麻烦一个数量级。CSV 是纯数据Excel 里埋着格式、公式、多个 Sheet、合并单元格、日期类型等各种隐雷。我在项目里处理最多的问题有三个多 Sheet 映射、合并单元格导致的空值、公式值读不到。3.1 选型PandasExcelReader 还是 openpyxl 自己来LlamaHub 里有两个相关的 ReaderPandasExcelReader和PandasCSVReader之类。PandasExcelReader 的定位是「把每个 Excel 文件转成一个或多个 Document」内部用pd.read_excel对单 Sheet 的简单表够用但对复杂 Excel 不够灵活。复杂场景我基本直接用pd.read_excel(file, sheet_nameNone)把所有 Sheet 一次性读进来再逐个自定义处理。这样每个 Sheet 是独立的 DataFrame你可以为不同 Sheet 配置不同的 table_desc、不同的空值策略、不同的行文本模板。另外一个值得记住的点pd.read_excel需要指定引擎。.xlsx默认 openpyxl.xls需要 xlrd。旧版.xls文件现在反而少见但遇到时引擎报错很容易让人懵建议直接.xls文件先用 openpyxl 另存为.xlsx别跟它较劲。3.2 多 Sheet 怎么拆元数据怎么标多 Sheet 的坑在于不同 Sheet 可能是完全不同的业务表。比如一个「月度销售报表.xlsx」里Sheet1 是订单明细Sheet2 是区域汇总Sheet3 是产品目录。如果你把它们当成同一个文件处理混在同一个索引里检索时 Sheet 之间的相互干扰非常严重。正确做法是每个 Sheet 独立构建 Document并且把sheet_name放进 metadataimport pandas as pd from llama_index.core.schema import Document def load_excel_workbook(file_path, sheet_to_table_descNone): # sheet_to_table_desc 是 {订单明细: 订单记录, 区域汇总: 区域销售统计} dfs pd.read_excel(file_path, sheet_nameNone, dtypestr) documents [] for sheet_name, df in dfs.items(): table_desc (sheet_to_table_desc or {}).get(sheet_name, sheet_name) # 清洗逻辑同 CSV略 for idx, raw_row in df.iterrows(): row raw_row.dropna() text row_to_text(row, table_desc) documents.append( Document( texttext, metadata{ source: file_path, sheet: sheet_name, table: table_desc, row_index: idx, }, ) ) return documentsmetadata 里加sheet字段后检索时可以做到表级过滤——用户问题里出现「区域汇总」相关描述时只在该 Sheet 的 Document 范围内检索这比把所有 Sheet 混在一起喂给模型要准得多。后面第 5 章我会细讲过滤和路由的用法。3.3 合并单元格和公式缓存两个低频但致命的问题合并单元格是 Excel 导入里最阴间的坑。比如一个产品目录表产品名称列做了纵向合并合并后pd.read_excel读进来只有合并区域的第一行有值其余行全是 NaN。如果你不做处理导入后的文本里那些行就缺了产品名称而数据本身是存在的你只是没读到。处理合并单元格的常见办法是 forward fill# 对需要前向填充的列做合并单元格还原 df[产品名称] df[产品名称].ffill()注意ffill只能解决「上方合并」的情形如果有横向合并单元格需要ffill(axis1)但横向合并语义复杂建议先输出到 Excel 里肉眼确认一遍再动。公式缓存又是另一个坑。某些 Excel 单元格是公式比如SUM(C2:C20)。openpyxl 读单元格值时有data_onlyTrue参数能返回上次 Excel 打开文件时缓存的计算结果但如果文件是程序生成、从来没被 Excel 打开过缓存就不存在读出来是 None。pandas 的read_excel内部按缓存值读取一旦遇到无缓存公式你会得到一列空的数值而用户看到原始 Excel 里明明是有的。我的建议导入 Excel 之前先用办公软件把文件打开并另存一遍这样公式缓存基本都在如果文件是程序自动生成的优先去源头导出「值」而不是「公式」。另外可以在导入后做个数量级校验统计每个数值列的 sum对比业务方给的手工数字差太多就说明公式或合并单元格出了问题。4. 数据库连库实战LlamaHub DatabaseReader 用法拆解前两章讲的 CSV、Excel 本质上还是「文件导入」会有更新不及时、数据漂移的问题。数据库表的正确姿势是连库直接读LlamaHub 里的 DatabaseReader 就是干这个的。这一章把配置、查询参数、行文本化全部过一遍。4.1 为什么连库而不是导出 CSV你可能会想「我从数据库里 SELECT 出来导出个 CSV再走第二章的方案不就行了吗」对于一次性迁移可以但如果是持久化知识库连库有不可替代的优势。第一时效性。很多 RAG 项目要求数据按天更新你要是定期导出 CSV 再触发导入链路长、容易漏。连库读取可以做到每次构建索引时直接查最新数据或者做增量查询。第二数据一致性。手工导出 CSV 容易丢类型、丢精度尤其大数值和带时区的时间戳。直接从数据库查询拿到的值更准也能保留主键和索引字段方便后面增量去重。第三可溯源。连库导入时可以直接把主键或业务 ID 放进 metadata出问题查原始记录比靠 row_index 猜快得多。4.2 DatabaseReader 的配置、查询参数与行文本化LlamaHub 的 DatabaseReader 核心依赖 SQLAlchemy所以只要你安装了对应对应库的方言驱动比如 psycopg2、pymysql就能连 PostgreSQL、MySQL、SQLite、SQL Server 等主流数据库。基础用法如下from sqlalchemy import create_engine from llama_index.readers.database import DatabaseReader engine create_engine(postgresql://user:passwordlocalhost:5432/mydb) reader DatabaseReader(sql_databaseengine) documents reader.load_data( querySELECT * FROM orders WHERE created_at 2024-01-01 LIMIT 10000 )load_data的不同之处在于它需要你传入一个query参数而不是像文件 Reader 那样传路径。每个返回行会被转成 Document列名会变成 metadata 的一部分。这个设计的好处是你可以用 SQL 完成大部分清洗工作WHERE过滤、条件聚合、甚至 JOIN 后直接 SELECT 出你想要的上下游上下文。但我实际操作中的体会是DatabaseReader 直接返回的 Document text 仍然偏机械基本是 key: value 形式。所以我对它的用法是先用它拿到 DataFrame 或 List[Dict]再走一遍自定义行文本化和 metadata 构建。如果你不想改源码可以不用 load_data而是自己用 SQLAlchemy 查询import pandas as pd from sqlalchemy import create_engine engine create_engine(postgresql://user:passwordlocalhost:5432/mydb) sql SELECT order_id, product, region, amount FROM orders WHERE created_at 2024-01-01 df pd.read_sql(sql, engine) # 复用第二章的 row_to_text 和 Document 构建逻辑 documents [] for idx, row in df.iterrows(): text row_to_text(row, 订单记录) documents.append(Document(texttext, metadata{ source: postgresql://mydb/orders, row_id: row.get(order_id), table: orders, }))这个方案的可控性更高适合不同库之间字段差异大、需要逐个定制的情况。DatabaseReader 的价值在你图省事、表结构干净时最能体现表结构复杂时我建议自己写查询。4.3 行级自然语言转换让向量检索真正「看懂」数值连库读数据有个额外挑战数据库表往往字段名是英文或缩写比如pc_name、qty、amt_usd。如果你直接把这些英文列名带进文本embedding 的效果会打折扣——中文用户问「哪个产品代码卖得最多」和你索引里的pc_name完全对不上。我目前最常用的方案是在 SQL 查询层做好「列名自然语言映射」直接 SELECT 出自带语义的文本字段SELECT order_id AS 订单编号, product AS 商品名称, region AS 销售区域, amount AS 订单金额元, status AS 发货状态 FROM orders WHERE created_at 2024-01-01;然后把 pandas 读出来的 DataFrame 逐行转自然语言效果比直接让 reader 转好很多。此外某些库里有枚举型、字典型字段比如status 1表示已发货。严格来说这属于数据清洗但放在导入链路里做最高效SQL 层面 JOIN 字典表把1翻译成「已发货」再进 RAG。否则你问「已发货的订单」永远匹配不到status1。5. 导入只是开始召回验证与数据同步表格数据导入完成不等于 RAG 就能回答业务问题。入库之后你要回答一个更实际的问题「它到底能不能把正确的那几行捞出来。」这一章是全篇的临门一脚也是最容易被跳过的一步。5.1 召回测试问一遍真实业务问题索引构建完不要着急接 query engine先做一次最小的召回测试。用index.as_retriever(similarity_top_k5)把业务方那十几个真实问题逐一输入直接看召回出来的 top-5 Document 文本。这一步不用让 LLM 生成回答只看检索质量。举例来说索引里是订单表你问「华北区最近有哪些大额订单」如果 top-5 里混着华南区的订单说明 region 字段没有在文本和 metadata 里被充分表达或者行文本生成得不够清晰。这时候调行文本模板比调 embedding 模型参数有效得多。如果发现某类问题总是召不回正确行大概率是行文本里缺少用户会用的同义词。比如用户习惯说「大客」你的表里是「重点客户」那就在行文本生成时把别名也拼进去。这类词表工作很笨拙但效果好。5.2 用 metadata 做表级路由与过滤表格数据进 RAG 后多张表共存时最大的噪声源是「问 A 表问题召回了 B 表数据」。解决的关键是 metadata filter。你可以在构建 retriever 时按表过滤retriever index.as_retriever( similarity_top_k5, filtersMetadataFilters( filters[MetadataFilter(keytable, value订单记录)] ) )更进一步可以用函数判断问题的关键词来决定使用哪个 filter。比如问题里出现「区域汇总」就过滤table区域销售统计出现「订单」就过滤table订单记录。这就是表级路由比让 LLM 自己猜要稳定得多。我建议每张表导入时都把一个标准化的table字段写进 metadata并保证全库统一命名。命名不规范比如一张叫「订单记录」、另一张叫「orders」路由就得写两套逻辑。5.3 增量更新的两种策略与 doc_id 去重对于数据库表这种变更频繁的数据源全量重建只适合初期或小数据量场景。数据量大以后必须做增量。我实践下来有两条可行的路。第一条是时间戳增量。在表里加一个updated_at导入时记录上次同步时间last_sync每次只查WHERE updated_at last_sync。这个方案防漏更新但对删除操作不敏感某行被删了索引里还留着。第二条是 doc_id 去重重建。给每个 Document 设置doc_id为业务主键比如order_A1001。同步时不管新增还是更新全部插入插入前先把已存在的同 doc_id 文档删掉或标记覆盖。LlamaIndex 的index.delete(doc_id)和index.insert(document)配合就能做到。这个方案能处理更新和删除但每轮要全量拉取需要同步的数据适合表数据量可控的场景。两种方案也可以组合时间戳圈定范围doc_id 去重保证更新。对大多数中小规模业务表这个组合已经非常稳了。关于同步频率我的经验是不要每次都重建整个索引。如果只有几百行增量直接 insert 新 Document 到已有索引里快很多。如果增量超过总量 30%重建可能更划算因为向量索引的删除累积会留下碎片检索性能会慢慢下降。表格数据进 RAG我现在越来越觉得做得好的关键不在于用了多高级的 embedding 或者框架而在于导入前的数据建模和导入后的验证闭环。CSV 要处理编码和空值Excel 要解决 Sheet 和公式问题数据库连库要设计好查询和行文本。每一步都有固定的坑踩过一遍之后后面基本就是复制模板的问题。按照上面这套流程走一遍你的表格知识库应该能覆盖绝大多数业务问答场景。