ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

表格与数据库数据进RAG:从CSV、Excel到LlamaHub的完整实战

表格与数据库数据进RAG:从CSV、Excel到LlamaHub的完整实战

做RAG做到一定阶段,你会发现一个尴尬的事实:那些PDF、Markdown、网页类文档喂进去,检索效果还能凑合,一旦上表格和数据库数据,整个知识库就开始"犯迷糊"。要么答案张冠李戴,要么直接一本正经地编造不存在的统计数字,甚至把A表的字段挂到B表的列头上。这个系列前两篇我们聊了PDF和各类文档的解析,这篇是第三篇,专门拆表格和数据库这条线——CSV、Excel,以及通过LlamaHub连接数据库并拉进RAG管道的完整实战。适合正在搭建知识库的工程师、被结构化数据检索折磨的算法同学,以及准备把业务数据库接入问答系统的团队参考。

先说结论:表格类数据进RAG,核心不是"能不能读出来",而是"读出来之后怎么保真"。文本切碎了大不了上下文丢一点,表格切碎了那叫灾难。这篇文章我会按"CSV→Excel→数据库(LlamaHub)→Chunk策略→踩坑复盘"的顺序,把每一步的具体操作、参数选择和排查思路都摊开讲。

1. 为什么表格和数据库数据是RAG最容易翻车的环节

表格数据在RAG管道里翻车,不是某一步代码写得不对,而是整条链路的设计前提跟结构化数据的组织方式天然冲突。搞清楚这个底层原因,后面所有调参和绕坑才有方向。

1.1 文本分块与表格行列的天然矛盾

RAG的基石是"文本"。加载、切分、embedding、召回,每个环节都假设输入是段落级别的自然语言。表格不是这么组织的——一张100行5列的销售明细表,按固定chunk_size=512硬切,你会得到一堆残缺的行片段。更麻烦的是列头,第一列的列名"订单编号"和第二列的列名"客户名称"被切开,分属两个chunk,检索"客户名称"时只命中一半上下文。

embedding模型本身也不擅长表达"行和列之间的位置关系"。文本里的词是线性的,表格里的值既跟左边的列头有关系,又跟上面那一行同一个记录的其它字段有关系。两套关系同时在,但向量只编码了线性token,位置信息天然丢失。这就是为什么直接拿RAG查表格经常出现"列头对不上值"的诡异答案。

1.2 三种典型症状:丢列头、乱类型、上下文割裂

我把实战中遇到的翻车归纳成三类,方便你对号入座:

  • 丢列头:检索出来的chunk只有数值没有表头,比如"3456、789.2、华东区",你根本不知道这段在说什么。原因多半是切分时列头所在的chunk没被召回,或者表格被转成了纯文本后列头被当成普通文本稀释掉了。
  • 乱类型:日期变成浮点数(Excel的经典表现),金额被读成字符串,百分比被读成小数。embedding模型对这些数字变化很敏感,"2024-01-15"和"45289"两个表达方式,向量距离有时比不同日期之间还远。
  • 上下文割裂:一次查询涉及同一行的多个字段,比如"华东区Q2的退货率",退货率在某几列,华东区在某几列,Q2在时间维度上,切分后它们散落在不同chunk,检索只召回部分,答案自然残缺。

提示:在开始设计解析流程之前,先明确一个目标——你要保留的是"表格的结构可读性",还是"表格的语义关系"?两者对解析方案的选择完全不同,下面每一章都会回到这个判断上。

2. CSV导入:看似简单,其实暗坑都埋在细节里

CSV是表格数据进入RAG最直接的方式,但恰恰因为它"太简单",很多人栽在编码、分隔符和类型推断这三个基础问题上。

2.1 编码问题:UTF-8 BOM与GBK的兼容

CSV的编码是第一个坑。国内业务系统导出的CSV,超过一半是GBK或GB18030编码,还有一部分带BOM头。如果你直接用默认方式读取,GBK文件会出现乱码,BOM头则会被当成字符混进第一列列名里。

我的处理习惯是读取前先做编码探测,不要硬猜。用chardet或charset-normalizer跑一遍,或者干脆写一个兼容层:

import pandas as pd def read_csv_auto(path): # 先尝试常见编码,再降级到探测结果 for enc in ("utf-8-sig", "gb18030", "big5"): try: return pd.read_csv(path, encoding=enc) except UnicodeDecodeError: continue # 实在不行用探测 import charset_normalizer raw = open(path, "rb").read() enc = charset_normalizer.from_bytes(raw).best().encoding return pd.read_csv(path, encoding=enc)

注意utf-8-sig和utf-8的区别在于前者会剥离BOM。我通常把utf-8-sig排第一,因为即使文件没有BOM,它也能正常读取,不会报错。

2.2 分隔符识别:逗号、分号、Tab与引号转义

CSV的"CSV"全称是逗号分隔值,但现实世界里分号、Tab、竖线都是常见变体。欧洲各区用分号,数据库导出的TIMESTAMP字段里混着逗号,某些工具导出的是TSV。如果你写死sep=",",必炸。

更稳妥的做法是让csv.Sniffer去猜分隔符,但这个方案在流式读取和大文件上有性能坑。我采用的是"先抽样再指定"的策略:

import csv def sniff_delimiter(path, sample_size=4096): with open(path, "r", encoding="utf-8", errors="replace") as f: sample = f.read(sample_size) dialect = csv.Sniffer().sniff(sample) return dialect.delimiter

抽样的好处是速度快,只读文件头部。但要注意,如果文件前几行是注释或空行,sniffer可能猜出错误结果。我会先跳过空行和以#开头的行再抽样。

还有引号转义问题。CSV字段里包含换行符、逗号和引号时,解析器是否正确处理双引号转义,直接决定了读出来的内容是否完整。Python的csv模块处理这个没问题,但如果你用简单的split(","),遇到带引号的多行字段会直接错乱。这个几乎是所有CSV踩坑中最隐蔽的。

2.3 类型推断与空值处理

pandas的read_csv默认会尝试类型推断,但这套推断在RAG场景下不太够用。比如订单号"00123"会被转成整数123,后续检索"订单号00123"永远得不到结果。处理方式是指定dtype参数,把不可转换的列显式设为字符串:

df = pd.read_csv(path, dtype={"order_id": str, "sku": str})

空值的处理也值得注意。RAG管道中,表格里的空单元格不应该被简单丢弃,因为空值本身可能携带信息——"这个客户没有邮箱"和"这个客户邮箱未采集"是两回事。我会在导入阶段把空值统一标记为"N/A"或保留占位符,而不是直接让NaN进入embedding。原因很简单,NaN在向量化时通常被忽略,但"缺失"这个语义信息有价值的场景非常常见。

注意:CSV进RAG之前,做一次df.info()和df.nunique()统计,看列数是否符合预期、每列的唯一值数量是否合理。这一步能拦住绝大多数脏数据问题。

3. Excel导入:你以为它就是多了个Sheet?

Excel比CSV麻烦的地方不只是"多一个Sheet"这么简单。作为二进制或压缩XML格式,Excel的读取链路里藏着一堆CSV里根本不存在的坑。

3.1 多Sheet与表头定位

一个工作簿里有多个Sheet是常态,每个Sheet可能还有不同层级的表头——有的表头在第1行,有的在第3行(上面是标题和生成日期)。直接用pd.read_excel默认读第一个Sheet,表头位置错一位,整个列映射就全乱了。

我一般建议做成显式配置:

xl = pd.ExcelFile("data.xlsx") for sheet_name in xl.sheet_names: df = xl.parse( sheet_name, header=2, # 表头在第3行 skiprows=[0, 1], # 跳过前两行元信息 usecols="A:F" # 限定列范围 )

不要偷懒用sheet_name=0这种索引,因为在RAG项目里,根因追溯极其重要——你得能说出"这个chunk来自data.xlsx的哪个Sheet的哪几行",否则排查检索质量时无从下手。

3.2 合并单元格、公式缓存值与日期序列号

Excel的合并单元格是另一个经典暗坑。openpyxl读取合并单元格时,只有左上角那个单元格有值,其余返回None。pandas读出来之后你会看到大量NaN,而这些NaN在业务上其实是"继承上方单元格的值"。

处理办法是向前填充(forward fill):

df = df.ffill()

但要小心,这个操作只适用合并方向是列方向的场景,不要对整个DataFrame无脑ffill,否则会把不同分组的空值也串起来。

公式问题是Excel特有的。openpyxl默认读取的是公式计算结果缓存值,也就是Excel最后一次保存时计算的值。但如果这个文件是用代码生成的,或者保存时Excel没有触发重算,缓存值可能跟实时计算值不一致。更隐蔽的是,用openpyxl的data_only=True读取时,如果没有缓存值,会直接返回None。

日期序列号更是经典中的经典。Excel内部把日期存储为从1900年1月1日起的天数序列号,比如45289其实代表2024年1月1日。如果你读取时没有指定parse_dates,pandas可能给你一串整数,embedding之后完全变成无意义数字。我在实战中一定会显式指定:

df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")

3.3 大文件与内存:xlsx是zip,不是XML

很多人以为xlsx就是XML,其实xlsx是一个zip包,内部包含多个XML文件。用openpyxl读取大文件时,它是把整个工作簿加载进内存的,50MB的xlsx可能吃满几个GB内存。这跟pandas读取CSV的流式机制完全不同。

如果你要处理超大Excel,我建议用openpyxl的read_only模式逐行读取:

from openpyxl import load_workbook wb = load_workbook("big.xlsx", read_only=True, data_only=True) ws = wb["Sheet1"] for row in ws.iter_rows(values_only=True): # 逐行处理,而不是一次性加载整个DataFrame process_row(row)

不过read_only模式不支持读取合并单元格的隐藏值,两者是有取舍的。我的经验是:小文件追求功能完整用普通模式,大文件追求内存可控用read_only,并且提前用脚本把Sheet拆分成CSV中间文件,再走CSV流程。

4. LlamaHub连库实战:数据库到向量库的完整链路

LlamaHub是LlamaIndex生态里的数据加载器和市场,它最大的价值不是省去你写几行代码,而是把许多数据库的"连库→取数→结构化"的脏活提前封装好了。下面我讲的是通用实战链路,不绑死某个具体加载器,而是把思路和关键参数讲透。

4.1 数据库连接前要想清楚的三个问题

不是所有表都适合直接进RAG。连库之前,先回答三个问题:

  • 哪些表是维度表,哪些是事实表?维度表(客户、产品分类)拥有大量文本属性,适合作为知识库内容。事实表(订单明细)通常只有外键和数字,直接进RAG的价值很低,且容易产生数据噪声。
  • 数据量有多大?全量拉一张千万级流水表进向量库,成本高且大部分数据根本没有查询价值。要设计采样或条件过滤。
  • 是否需要实时性?知识库是离线快照还是准实时同步?这决定了你是否要引入增量更新机制。

在LlamaIndex中,我通常用SQLDatabase来建一个可查询的数据库连接对象,再用DatabaseReader的变体或自定义包装方式把指定表拉出来:

from sqlalchemy import create_engine from llama_index.core.objects import SQLDatabase engine = create_engine("mysql+pymysql://user:pass@localhost/mydb") sql_db = SQLDatabase(engine)

这一步的核心价值是让RAG管道能理解"数据库里有哪几张表、各表有哪些字段",而不是直接把SQL结果一股脑倒进embedding模型。

4.2 连接配置与数据分页

连库取数最常见的坑是"一次性查询把所有行都读进内存"。数据库数据动辄几十万行,直接SELECT *会把应用机器内存打爆。正确做法是使用分页查询:

PAGE_SIZE = 1000 def fetch_all_data(sql_db, sql, page_size=PAGE_SIZE): # 基于游标分段读取,避免一次性加载全量结果 offset = 0 while True: paged_sql = f"{sql} LIMIT {page_size} OFFSET {offset}" chunk = sql_db.run_sql(paged_sql) if not chunk: break process_chunk(chunk) offset += page_size

这里有几个参数需要注意:

  • LIMIT/OFFSET分页在数据量大时越翻越慢,更优方案是基于主键范围或游标(WHERE id > last_id)分页。
  • 每页大小取决于单行的字段长度和embedding批量上限,一般500-2000之间比较稳。
  • 超时设置:很多数据库默认查询超时很短,大查询要主动设置连接超时参数,比如MySQL的connect_timeout和read_timeout,否则跑到一半被断开,白跑。

4.3 增量同步与主键去重

知识库要落地,一次性导入是不够的。业务数据库每天在变,你的向量库也得跟着变,否则三个月后知识库就成了历史包袱。

我的增量同步模板分三步:

  1. 记录同步水位线:用一张表或者元数据文件保存上次同步到哪个主键ID或时间戳。
  2. 增量查询:WHERE updated_at > last_sync_time或WHERE id > last_id。
  3. 主键去重:核心是让chunk的node_id带上业务主键,这样写入向量库时天然支持upsert。

LlamaIndex中创建带稳定ID的节点:

from llama_index.core.schema import TextNode def create_nodes_from_row(row, table_name): node_id = f"{table_name}:{row['id']}_{row['updated_at']}" text = format_row_as_text(row) # 转成自然语言/表格文本 return TextNode( text=text, id_=node_id, metadata={"table": table_name, "row_id": row["id"]} )

把id_设为业务主键加时间戳,重建索引时就能通过ID去重,而不是重复插入相同内容。这一步我见过太多团队漏掉,结果是知识库里有几十份重复的同一行数据,检索结果全是同一个信息的变体,召回质量稀碎。

5. 表格数据进了Chunk之后的语义保真问题

数据读进来了,接下来是更关键的Chunk策略。表格数据怎么切,直接决定检索质量的上限。文本可以无脑按token切,表格不行。

5.1 Markdown转表格 vs 每行一个Chunk

我把表格进RAG的策略分成两派:

  • 方案A:转成Markdown表格后整体分割。把表格渲染成Markdown的表格语法,再按行或按固定token切。优点是保留了列头与数值的相对位置关系,embedding时表格结构和语义能部分保留。缺点是Markdown表格在切分边界处依然可能被截断,切出来的片段失去了表格的完整性。
  • 方案B:每行转成一个独立Chunk,再把列头拼进该行的前面。例如"客户名称: 张三; 订单编号: 00123; 产品: 无线耳机; 数量: 2"。这种做法的优点是完全避免跨行截断问题,每一条记录作为一个独立单位被检索;缺点是失去了"整张表"的全局统计信息,如果你想问"这个月销售额最高的产品是什么",单行Chunk是答不出来的。

我的实践是混合使用。对于要支持明细查询的场景("张三买了什么")用方案B;对于要支持统计查询的场景("总收入多少")必须额外保留整张表的大Chunk或走Text-to-SQL路线,而不是指望embedding模型从零散行里算汇总。

5.2 列头拼接策略

不管用哪种方案,列头拼接都值得单独处理。很多人直接把表头丢掉,只喂数据行,这是最糟糕的做法。

我一般用这样的列头拼接方式:

def format_row_as_text(row, columns): parts = [] for col in columns: value = row[col] if pd.isna(value): value = "N/A" parts.append(f"{col}: {value}") return "; ".join(parts)

关键点是:列名本身要保留在文本里,因为它是回答时最核心的"锚点"。用户问"哪个产品销量最好",如果embedding结果里没有"产品"和"销量"这两个词,召回必死。

有个细节:列名可以用中文保留原样,不需要强行翻译成英文。embedding模型对中文列名的理解通常比英文翻译更准确,因为业务表格的列名本来就是中文语境下的专有词汇。

5.3 结构数据转自然语言的权衡

还有一种思路是"结构化转自然语言",把每一行翻译成一句通顺的陈述句,例如:

张三在2024年1月15日购买了订单编号为00123的无线耳机,数量为2,单价为399元,订单状态为已发货。

这种做法的好处是embedding效果通常比"列名: 值; 列名: 值"更好,因为自然语言的结构更接近模型训练数据的分布。但缺点是转化成本高,尤其是复杂的多级表头,很难自动化生成流畅的句子。

我的选择标准是这样的:数据量小、字段少时用自然语言转换;数据量大、字段多时用"列名: 值"拼接,因为它更好自动化、更稳定。不要为了追求"自然"而牺牲批量处理的可控性。

6. 实测中的翻车案例与解法复盘

最后分享三个我在实际项目里踩过的坑。这些问题都有一个特点:代码不报错,但检索结果悄悄变差,往往要过很久才被发现。

6.1 案例一:CSV的GBK编码导致检索乱码

现象:知识库上线后,用户反馈搜索"华东区"能找到内容,但搜索"华东"就出现乱码字符。排查发现,CSV文件是GBK编码,我用默认utf-8读取,解析结果在检索时偶尔正常偶尔乱码——因为pandas对无法解码的部分默认替换成了�,这些替换符混进了embedding文本。

解法:在读取函数中加编码探测,并且对结果做一次"是否包含替换字符"的校验:

if "\ufffd" in raw_text: raise ValueError("CSV存在未识别的编码字符,请检查源文件编码")

不要等到检索结果出问题再来返工,读取阶段就要拦截。

6.2 案例二:Excel公式缓存值比实时值旧

现象:一个销售报表工作簿里的"本月总销售额"列是用Excel公式计算的,系统导出时没有强制重算,缓存值停留在上周。查询"这个月卖了多少"时,RAG给出的答案和真实数据库差了38%。

解法:读取Excel时用data_only=False拿到公式本身,判断公式引用的单元格范围,再把对应的数据区域算一遍。如果项目里Excel都涉及公式,我建议直接写一个独立校验脚本:每周对公式单元格做一次全量重算,用Excel的openpyxl写回结果。

这个坑的隐蔽性在于,你只看到"数字",看不到"这个数字是缓存还是实时值"。

6.3 案例三:数据库连表查询超时

现象:用LlamaHub连接业务库时,查询一个关联了7张表的视图,RAG管道在那里卡了30秒后超时。原因是视图本身在数据库端执行要25秒,而我在RAG侧设置了20秒的查询超时。

解法:围绕性能和稳定性做三层处理。第一,把视图的SQL在数据库端先执行并落到中间表,RAG直接查中间表;第二,给连接池设置合理的超时和重试参数,不让单次查询拖垮整个管道;第三,对查询语句做EXPLAIN分析,确认没有全表扫。数据量大的表加上合适的索引。

提示:任何连库的时间超过1秒,都要警惕。RAG检索的端到端延迟应该控制在几秒内,如果数据拉取占了大头,用户体验根本扛不住。宁可离线把表打成快照文件,也不要在线查库。

7. 写在最后:表格进RAG的几条经验底线

表格和数据库导入这条路,我走了很多弯路。如果只留几条经验给后来者,我会说:

第一,永远不要让原始表格以"原样"进入embedding。至少要经过列头拼接、空值标记、统一编码这三道工序,才能保证向量化的文本真的"读得懂"。

第二,CSV、Excel、数据库三者的解析策略完全不同,不要试图用一套代码通吃。CSV要处理的是编码和分隔符,Excel要处理的是合并单元格和公式缓存,数据库要处理的是分页、增量和超时。每一类数据都有自己最核心的那一两个坑,提前设计好,后面会省很多事。

第三,在引入LlamaIndex这种框架时,不要只看到"方便",要理解封装后面的数据结构。SQLDatabase到底暴露了哪些表、字段是不是按你期望的类型进了DataFrame、主键有没有被保留下来——这些细节决定你做增量更新时会不会崩。

我现在的标准流程是:CSV和Excel先做清洗校验,转成UTF-8编码的中间文件;数据库数据先落中间表快照,再走同一套"列头拼接→文本化→Chunk→索引"的流程。这样架构简单,排错也容易——任何一个环节出问题,都能快速定位到具体文件或表,而不是在框架的黑盒里猜。

最后再补一个我实测很有用的小技巧:表格类数据进索引之后,跑一轮"检索冒烟测试",用10到20个典型业务问题去查,看返回文本的前100个字符里有没有你要的列名和值。这个测试成本很低,但能在上线前拦住绝大多数表格解析问题,比上线后靠用户反馈再修要划算得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进