做 RAG 的项目很多团队前期把大量精力花在清洗 PDF、Word 这类非结构化文本上结果一碰到表格和数据库就集体翻车。CSV 和 Excel 看起来简单不就是查个表、读个值吗但一旦让 LLM 去回答上季度华东区销售额最高的三个产品分别是什么这种跨行跨列的问题你会发现普通切块方案全部失灵。这一篇我把处理表格类数据的实战经验完整梳理了一遍重点放在 CSV、Excel 和通过 LlamaHub 连数据库这三条路线上每一段都会给出可以直接抄走的代码和注意事项。1. 表格数据喂给 RAG 为什么容易翻车核心痛点与破解思路1.1 按字符切块会毁掉表格结构先理解一个问题RAG 流水线里最常见的操作是TEXT_SPLITTER按字符数硬切这套打法对纯文本没问题但对表格就是灾难。一个 60 列的销售明细表按 1000 字符切完每个 chunk 里可能只剩 5 列、8 行表头信息页眉、字段名在第一段之后全丢了。等检索阶段用户问某客户在 7 月的采购额向量检索拿到的往往是残缺的半张表根本拼不回完整的语义。而且表格的语义是二维的列名决定字段含义行代表一条记录表头是整个表的命脉。切块后如果表头和内容分离模型只能靠猜。我见过不少方案直接把 CSV 文件读进来塞给向量库效果极差原因就在这。1.2 RAG 知识库和结构化知识库的边界这里要顺带区分一个高频混淆点RAG 知识库和结构化知识库不是一回事。RAG 知识库的载体是向量索引适合语义检索 生成适合回答这个产品的优势有哪些这类开放问题。结构化知识库比如知识图谱 KG、关系型数据库、Ontology 体系则适合精确查询、聚合计算、跨实体多跳推理比如哪个供应商提供了最多的 SKU 且交货准时率低于 90%。表格数据天然介于两者之间它既不是纯文本也不像图结构那样有复杂关系。所以选型时你要问自己一个问题——用户的 query 到底是语义查找还是结构化查询如果是前者我们可以把表格转成带上下文的文本块进 RAG如果是后者正确做法是让模型生成 SQL 去查库。这决定了后面整条链路的架构。很多热搜里说的RAG 瓶颈本质上就是在该走结构化查询的时候硬走语义检索。1.3 表格入库前的统一策略表格无论从哪个来源来建议先做一道统一的预处理流水线格式标准化统一编码、分隔符、列名大小写去掉不可见字符类型清洗把日期、数值、布尔值明确识别出来而不是全当成字符串语义增强每行数据补上表头和必要上下文让每个 chunk 自解释行级溯源保留行号、来源文件名、Sheet 名方便回答时引用这套流程做完之后再决定向量化还是规约化。向量化适合语义检索规约化比如压缩成摘要或元数据适合快速过滤。实际项目里两者经常结合用。2. CSV 导入按行切一切就完事了吗——从编码到语义块2.1 CSV 的三个隐藏坑编码、BOM、分隔符CSV 看似简单但导入失败九成是编码问题。默认用utf-8读取 Excel 导出的 CSV 时很容易报UnicodeDecodeError因为 Windows 下 Excel 默认可能保存为GBK或带 BOM 的utf-8-sig。解决办法是读取时做编码探测chardet或cchardet都能胜任也可以直接尝试utf-8-sig、gbk、utf-8依次回退。另一个坑是分隔符。CSV 的标准分隔符是逗号但很多业务系统导出的是分号、制表符甚至竖线。Pandas 的read_csv支持sep参数直接指定但更稳妥的办法是用csv.Sniffer自动识别分隔符。这里给一段稳妥的读取函数我平时做数据导入的第一步都会走它import csv import chardet def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(100000) result chardet.detect(raw) return result[encoding] or utf-8 def sniff_delimiter(file_path, encoding): with open(file_path, r, encodingencoding, errorsreplace) as f: sample f.read(4096) try: dialect csv.Sniffer().sniff(sample) return dialect.delimiter except csv.Error: return , def read_csv_robust(file_path): enc detect_encoding(file_path) if enc.lower().replace(-, ) utf8: enc utf-8-sig # 自动去掉BOM delim sniff_delimiter(file_path, enc) df pd.read_csv(file_path, encodingenc, sepdelim) return df注意utf-8-sig和utf-8的区别前者解析时会自动剔除文件头部的 BOM 字符免得第一列列名变成\ufeff订单号这个问题经常把不熟悉的人逼疯。2.2 逐行转 Document 的正确姿势CSV 处理最忌讳整表塞进一个 Document 再切块更好的做法是一行 表头 一个语义单元。也就是说从第二行开始每一行都拼接成一句话或一个键值对字典再转成 Document。我常用的模板有三种描述式订单号是 1001客户是张三产品是 A4 纸数量 200 箱金额 3200 元。键值对式{订单号: 1001, 客户: 张三, ...}适合模型做结构化检索Markdown 表格式把 CSV 转成 Markdown 表格再切块按行切分头部保留表头三种方式各有适用场景。描述式最自然Embedding 效果通常最好键值对式方便精确取数但占用 tokenMarkdown 式兼顾两者是多模态表格处理的折中方案。实测下来描述式在 Recall 上领先 10% 到 20%因为它最接近自然语言和用户 query 的语义空间贴合度高。下面是一个逐行转 Document 的示例除了内容metadata 里必须带上列名和行号from llama_index.core import Document def csv_to_documents(df, source_file): docs [] columns list(df.columns) for idx, row in df.iterrows(): desc .join([f{col}是{row[col]} for col in columns]) docs.append(Document( textdesc, metadata{ source: source_file, row: idx 2, # 跳过头行 columns: , .join(columns), } )) return docs2.3 大 CSV 的分批与去重几个 GB 的大 CSV 直接全部读进内存机器会先罢工。建议用chunksize参数分批读取同时做去重。去重的维度不一定是整行哈希通常业务上去重要看业务主键比如订单号、客户 ID。如果源系统里同一订单出现在两个文件中你要决定保留最新记录还是聚合再入库。这块逻辑错了RAG 回答里就会出现同一订单两个金额极其尴尬。还有一个很多人忽略的点入库前先把空行、全空列、重复表头行处理干净。有些业务导出文件会有统计说明那种垃圾行不过滤掉会成为检索噪音。3. Excel 解析的深水区多 Sheet、公式缓存与合并单元格3.1 多 Sheet 到底怎么拆Excel 比 CSV 麻烦在它是一个容器一个.xlsx里可能有好几个 Sheet每个 Sheet 含义完全不同。总表、明细表、汇总表、配置表混在一个文件里你要是整表读取再切块语义会互相污染。正确做法是先列出所有 Sheet 名称再逐个考察判断 Sheet 是否值得入库如果列数太少或行数极少可能是辅助区域判断 Sheet 类型是明细数据还是透视汇总两者的入库策略完全不同记录 Sheet 名到 metadata检索时可以按文件 Sheet 过滤处理多 Sheet 我建议不要用pd.read_excel()一把梭读全部而是用ExcelFile对象逐个读取便于控制每个 Sheet 的清洗逻辑import pandas as pd def load_all_sheets(path): xls pd.ExcelFile(path) sheets {} for name in xls.sheet_names: df xls.parse(name) sheets[name] df return sheets3.2 公式读取陷阱为什么你的数值全是 NoneExcel 单元格可能是公式openpyxl默认读取公式字符串而不是计算结果。这就是为什么很多人读 Excel 得到一堆None或公式文本但用 Excel 打开看明明有数字。原因在于公式的计算结果在 Excel 里是缓存值如果文件从来没有被 Excel 或 WPS 打开保存过缓存可能为空。解决办法有两个用openpyxl读取时启用data_onlyTrue它读取的是缓存值但要注意缓存缺失时返回 None不想依赖缓存就把文件先用 LibreOffice 批量转成 CSV 或 xlsx 再处理或者读取前用subprocess调 LibreOffice 命令执行宏刷新公式生产环境里我见过最稳的方案是Excel 文件先经过 LibreOffice 无头模式转换再走 CSV 流程。如果只是开发环境data_onlyTrue多数时候够用但别在生产环境赌缓存一定存在。3.3 合并单元格、日期数字、隐藏列脏数据三件套Excel 的合并单元格是 RAG 的隐形杀手。表头部分合并单元格会导致 Pandas 读出来多出来不少Unnamed列数据区域合并会导致部分行值为 NaN。处理思路是向前填充或向后填充但具体方向要看合并方向。日期列也容易出问题很多系统导出的日期在 Excel 里其实是序列数字比如45000表示某天。需要用pd.to_datetime转成标准日期否则 LLM 接到一个数字会一头雾水。处理办法如下def normalize_dates(df): for col in df.columns: if df[col].dtype object: try: df[col] pd.to_datetime(df[col], errorsraise) except (ValueError, TypeError): continue elif df[col].dtype int64 or df[col].dtype float64: try: df[col] pd.to_datetime(df[col], unitD, origin1899-12-30) except ValueError: continue return df这里origin1899-12-30是 Excel 日期序列的基准日不能填错否则差一天。隐藏列和空列建议直接剔除。隐藏列往往是无关的计算辅助列表头里那一堆Unnamed: 1也属于噪声。df.dropna(axis1, howall)先清全空列再根据业务规则决定保留哪些列。3.4 实操中的 Excel 解析代码骨架把上述逻辑整合成一个比较完整的清洗函数实际项目中可以直接往里面加规则def excel_to_clean_df(path, sheet_nameNone): df pd.read_excel(path, sheet_namesheet_name, header0) df df.dropna(axis1, howall) df.columns [str(c).strip().replace(\ufeff, ) for c in df.columns] df.columns [fcol_{i} if str(c) in (Unnamed: 0, nan, ) else c for i, c in enumerate(df.columns)] df df.dropna(subset[df.columns[0]]) # 去掉表头后的空行 df normalize_dates(df) return df配合data_only参数使用能覆盖 80% 的常见场景。4. LlamaHub 连库实测CSV、Excel 与关系数据库的统一入口4.1 LlamaHub 是什么以及为什么选它LlamaHub 是 LlamaIndex 生态里的一个加载器集合里面有好几百个数据源加载器从本地文件到各类数据库、API、SaaS 都有对应 Reader。它的好处是接口统一几乎每个 Reader 都有load_data()方法返回统一的Document列表这样就省去了为每个数据源手写专用解析器的工作。对 CSV 和 ExcelLlamaIndex 自带的CSVReader和PandasExcelReader可以直接使用对数据库有DatabaseReader对应 SQLDatabase 封装。用 LlamaHub 的好处不仅在于省事还在于它天然和 LlamaIndex 的索引体系打通Reader 读出来的 Document 直接可以喂给VectorStoreIndex或SummaryIndex不需要手动做格式转换。4.2 CSVReader 和 ExcelReader 的实测体会先用CSVReader读一个样例数据from llama_index.core import download_loader CSVReader download_loader(CSVReader) reader CSVReader() docs reader.load_data(file_pathsales_orders.csv)实测发现CSVReader默认把整个 CSV 按行拆分每一行都变成一个 Document但 metadata 里不区分表头和数据。对这个行为要心里有数如果你的 CSV 有 10000 行它会产生 10000 个 Document索引会非常大且每一条都是裸数据没有上下文。使用前建议自己先用 Pandas 做过语义增强再自己构造 Document而不是完全依赖 Reader。Excel 方面PandasExcelReader可以读取多个 Sheet但它返回的 Document 文本是 DataFrame 的字符串表示也就是整块 Markdown 表格文本。短文件没问题几百行以上的 Sheet 直接切块效果依然糟糕。所以我的建议是LlamaHub 的 Reader 适合快速试跑链路正式业务还是自己做逐行 Document 化。4.3 DatabaseReader 连 MySQL 和 SQLite 实战这是本篇最硬核的实战部分。LlamaIndex 的DatabaseReader实际上是对 SQLDatabase 的薄封装它可以让用户输入自然语言 query然后转换成 SQL 再执行查询。基本用法分几步创建数据库连接引擎初始化 SQLDatabase用 NLSQLTableQueryEngine 或 SqliteDBNode 等组件进行查询MySQL 的实例我实测最顺的写法是这样的from sqlalchemy import create_engine from llama_index.core import SQLDatabase from llama_index.core.query_engine import NLSQLTableQueryEngine # 连接MySQL engine create_engine( mysqlpymysql://user:passwordlocalhost:3306/demo_db?charsetutf8mb4 ) sql_database SQLDatabase(engine, include_tables[orders, customers, products]) query_engine NLSQLTableQueryEngine( sql_databasesql_database, synthesize_responseTrue, ) response query_engine.query(2024年7月每个客户的总订单金额是多少) print(response)这段代码背后的逻辑是这样NLSQLTableQueryEngine先把用户的自然语言问题翻译成 SQL在数据库里执行再把执行结果结合原始问题合成最终回答。include_tables参数很重要——如果数据库里有几十张表最好只放和业务相关的几张减少 LLM 选错表的概率。如果是 SQLite连接串换成sqlite:///local.db就行其他步骤完全一致特别适合本地快速验证。实测中有一个值得注意的点LLM 生成的 SQL 容易出现列名幻觉也就是模型猜了一个不存在的列名。解决办法有两个一是把每个表的 DDL建表语句注入到 prompt 里二是设置NLSQLTableQueryEngine的tables参数只允许查特定表。第二个方法更直接能显著降低错误率。4.4 连库的安全性和性能注意事项用 DatabaseReader 连生产数据库要非常谨慎因为自然语言转 SQL 本质上是让模型生成任意查询可能执行DELETE、UPDATE、全表扫描成本极高。必须做到用只读账号连接权限只给SELECT设置max_rows限制返回行数防止一条聚合查询拖垮数据库生产环境建议先用EXPLAIN检查 SQL 的执行计划或直接在查询前拦截非 SELECT 语句必要时做一个查询网关把所有模型生成的 SQL 先过一遍安全规则性能和缓存方面数据连接一次就够了不要每次 query 都重新做 schema 扫描。LLM 查询数据库的链路比向量检索慢很多单次可能要几秒到十几秒所以只适合低频精确查询高频量大的建议结果缓存起来或者直接走离线数仓。另一个经验连接数据库时同步把表结构和字段注释拉出来建一个元数据文档这个文档不用非常大只要能帮助 LLM 理解每张表是干什么的。把这份文档作为系统提示词的一部分生成的 SQL 准确率会明显提升。5. 表格问答的进阶路线text2sql 与语义检索的取舍5.1 两种架构的适用边界面向表格场景的 RAG最终要决定一件事问答请求是走向量检索还是走 text2sql还是两条腿走路。我给一个实用判断标准用户问的是事实型/查找型问题比如订单 1024 的客户是谁走向量检索可能答错但 SQL 秒出正确结果用户问的是分析型/统计型问题比如各区域销售额分布必须走 SQL向量检索无能为力用户问的是语义型/模糊型问题比如哪类产品口碑最好适合向量检索加文档上下文现实中一个典型的 RAG 问答系统往往是混合架构先通过意图识别判断问题类型SQL 能解决的问题绝不走向量SQL 不擅长的开放问题再召回文档。这个路由层可以用一个轻量分类模型也可以直接让 LLM 做 function calling。5.2 从表格到知识图谱何时升级热搜里频繁出现 KG 知识库和 ontology RAG很多团队问表格数据要不要转知识图谱。我的经验是只有当数据关系复杂到多跳查询成为常态时才需要引入图谱。比如找出和核心供应商存在间接合作关系的所有公司这种查询在表格里要 join 好几次在知识图谱里一次遍历就能完成。但建图谱的成本很高需要定义实体、关系、本体ontology还需要定期维护。对大多数业务场景来说数据库 text2sql 加一个索引良好的事实层已经够用了没必要为了追热点上图谱。先想清楚用户高频问题里有多少是语义开放型多少是精确查询型再决定架构复杂度不要在项目一开始就叠加一堆组件。5.3 混合检索的落地建议如果决定混合我推荐的落地方案是把表格数据分成两条通道通道 A抽取出表头 列注释 表注释做成数据库 Schema 文档进入 RAG 索引通道 B明细数据行转成描述性文本进入向量库用于模糊语义查找中间加一个SQL 路由层用户 query 先判断是否有明确的过滤条件或聚合需求命中则走 SQL否则走向量路由层用llm.predict加一段很短的 prompt 就能实现不需要训练模型。实测下来仅靠上述划分表格类问答的准确率就能从基本不可用提升到可用状态。6. 表格导入场景的端到端踩坑记录6.1 我遇到过的三类最隐蔽的问题一类是空值和异常值的语义污染。CSV 里某个字段是空逐行转描述文本时会变成客户是 None看似无害但检索时模型会把None当成一个实体回答里出现客户是空这种诡异话术。解决办法是转换时对空值明确处理可以跳过该字段也可以填未知。二类是数据源编码不一致。同一项目的 CSV 有时是 UTF-8有时是 GBK如果不做编码探测索引建立时就会静默丢掉一部分数据而且你不一定发现得了。我建议所有导入任务都做一个数据条数统计入库后的 Document 数和源文件行数对不上第一时间就要排查编码问题。三类是列名歧义导致向量冲突。不同文件里都有备注列但含义完全不同向量混合后语义会碰撞。解决办法是在列名映射阶段给每个来源加前缀或加上下文描述比如把备注改成采购单备注损失一点通用性但换来准确率。6.2 一次 MySQL 连库问答翻车的完整排查链路有一次我在测试环境做 MySQL 连库问答用户问供应链中交货延迟超过 7 天的订单有多少模型回答 0但 SQL 手动执行明明有 27 条。我花了两个小时才定位到问题模型把交货延迟直接映射成了表里的delayed_days列但业务上延迟超过 7 天需要比较plan_delivery_date与actual_delivery_date的差值模型跳过了运算逻辑。修好 prompt 后我在系统提示里补上了字段的业务含义明确要求延迟天数 actual - plan准确率立刻恢复正常。这个案例说明NLSQLTableQueryEngine 优化时首先要喂 Schema 语义而不是只给表名和列名。表注释和列注释是模型最好的老师能写详细就别偷懒。6.3 索引调优与多策略选择不同表格场景推荐的索引方案并不一样。我的整理如下数据形态推荐索引适用问题类型注意点短表几十行SummaryIndex / 直接进 Context全局概述别切块防止上下文丢失长明细表VectorStoreIndex行级文档语义查找行级 metadata 必须带表头跨表聚合NLSQLTableQueryEngine精确统计注意只读账号与表白名单多 Sheet ExcelVectorStoreIndexSheet 级摘要 行级切片跨 Sheet 语义问题Sheet 名写入 metadata数据关系复杂知识图谱Neo4j LlamaIndex多跳关系查询成本高先评估必要性现实项目里同一批数据往往要建多个不同的索引Query 时再按意图路由。数据量不大时这个开销可以忽略数据量大时就要用缓存和增量更新。6.4 从导入到上线前必须做的质检清单最后给出一个普适的质检清单每次上线前过一遍[ ] Document 数量与源数据量一致无静默丢失[ ] 空值、None、NaN 在文本中无残留[ ] 日期格式统一且已被正确解析[ ] 每行 metadata 包含来源文件、Sheet 、行号[ ] 测试 5 至 10 个典型问题逐一核对回答和真实数据[ ] 数据库账号权限已限制为只读[ ] SQL 结果有行数上限防止资源耗尽[ ] 向量检索和 SQL 路由的边界用例已验证这套流程走下来表格数据的 RAG 链路基本不会出大问题。平时我接手新项目时也是从头到尾跑一遍这个清单看起来慢但能省掉后面排错的大把时间。最后再分享一个小技巧CSV 和 Excel 导入前最好用脚本生成一份数据字典文档里面写明每个字段的类型、含义、取值范围和示例值然后把它作为系统提示词的一部分。这个文档对文本转 SQL 的提升非常明显甚至比其他花哨优化都有效。实际项目中我靠这个方法把 SQL 生成准确率从 70% 左右提到了 90% 以上强烈建议一试。