前两天有个同事截了一张Excel表格发给大模型,问“这几个片区哪个月增长最快”,模型一本正经地给出一串数字,对照原表差了十万八千里。问题不在模型笨,而在表格数据这种结构化内容,压根不适合直接“喂”给对话式模型去心算。这也是我想写这篇的原因:把“能算数的大模型RAG系统”拆开揉碎,讲清楚怎么让ChatGPT这类大模型不只是读得懂Excel,还能真正处理里面的数值逻辑。
这套方案解决的是“文档能检索但算不出数”的老大难问题,适合正在搭建企业知识库问答、报表自动化、表格类数据助手的开发者或数据分析师。它本质上是在经典RAG链路里,把“检索文本”和“工具化计算”组合起来:先用检索找到对应表格和字段,再把数值计算交给代码解释器去执行,最后让大模型组织答案。这样既保留了RAG的灵活性,又让结果经得起验算。
下面我会从方案设计、核心原理、完整实操到排坑经验,按一个可落地项目的顺序讲透。
1. 为什么普通RAG“不算数”:需求拆解与方案定型
1.1 大模型算不对表格的根源
很多第一次做RAG表格问答的人会踩同一个坑:把Excel文件切块、向量化、建索引,然后以为万事大吉。实际跑起来发现,问“总销售额是多少”这种简单问题还行,一旦问“第二季度环比增长率”“各区域最大的三个订单”,模型就开始一本正经地胡说八道。
问题出在哪?三个层面。
第一层是信息丢失。Excel表格本身是行、列、表头、单元格组成的三维结构,但切块向量化之后,通常只变成一段段的纯文本。比如一行数据被拆成“A区 2024-01 12873.50”,它作为文本片段被检索出来没问题,但模型看不到“A区到底属于哪个维度”“12873.50对应哪个指标”,上下文被截断了。
第二层是计算不可靠。大模型本质上是下一个词预测模型,让它做“求和”“环比”“取最大”这种精确计算,等于让一个文科生口算三位数乘法,偶尔能碰对,但不可依赖。而且表格数据里经常有大量数值,模型根本没法在上下文窗口里逐个读清楚。
第三层是表格语义复杂。同一个Excel里可能有多个Sheet,每个Sheet有不同的表头层级,还有合并单元格、日期格式、百分比、千位分隔符。这些在向量检索里都会变成噪声。
所以,想让大模型处理Excel,第一步就是承认它不擅长“心算”,让它学会“用工具”。
1.2 一条可落地的技术路线:检索与工具化计算并行
我最终定型的技术路线,可以用一个链路说明:用户提问 → 大模型判断该查哪个数据源 → 检索器定位表格和相关行 → 代码解释器执行精确计算 → 计算结果交回大模型 → 大模型整理成自然语言回答。
这里有两个核心动作:一个是检索,一个是计算。它们不是先后顺序,而是由大模型根据问题类型动态选择的。比如用户问“你们有哪些Sheet?”就只需要检索;问“B区一季度净利润是多少?”就得既检索表结构,又执行计算。
实现上我选用了LlamaIndex作为编排框架,因为它对表格类数据的支持比较完整,既有现成的Excel加载器,也允许自定义查询引擎和工具。计算层面用pandas,检索层面用向量索引加摘要索引的混合方式。大模型的推理层我用的是OpenAI兼容API,实际换成本地开源模型也没问题,关键是后面讲到的Prompt和工具调用机制。
1.3 需要提前规避的设计陷阱
有几个设计决策要在一开始定下来,不然后期返工成本很高。
不要把所有Excel一股脑向量化。有的表是数据明细(比如几千行订单),有的表是汇总表,两者检索策略完全不同。明细表更适合“先定位再查询”,汇总表则适合直接整表入索引。我的做法是按表用途分类,明细表走“表格描述检索+代码查询”,汇总表走“摘要索引+文本检索”。
不要忽略数值精度。pandas默认读取整数列时可能会变成int64,浮点列会有精度误差,读日期列时格式不统一。这些问题都会在计算阶段暴露出来,后面我专门有一节讲排查方法。
不要让模型直接看到原始Excel文本。很多人觉得把Excel转成Markdown表格喂给模型就行,但大表格超出上下文窗口不说,转出来的文本也经常带格式噪声。正确做法是保留结构化读取入口,给模型的是一个“可调用的工具”,而不是一大片文本。
2. 核心概念拆解:RAG、表格解析与工具调用
2.1 表格文档的特殊性:行、列、表头与语义
普通文档检索,检索单元是“段落”或“句子”,因为它们本身就是语义完整的自然语言片段。但表格不一样,一个单元格单独拎出来,几乎不含任何语义。比如“12873.50”脱离行、列、表头,什么也说明不了。这跟把一本书撕成单字撒在桌上,然后试图拼回原意差不多。
所以在做表格RAG时,检索单元要谨慎设计。我的实践是:表层元信息(表名、列名、每个字段说明)作为可检索片段,明细数据行本身不进向量库,而是留在pandas DataFrame里等查询工具取用。
打个比方:检索环节负责“找到是哪本书的第几章第几节”,计算环节负责“打开书翻到那一页,严格按公式算出结果”。前者是大模型的语义强项,后者是代码的精确强项,各干各的活。
2.2 “先检索后计算”的完整链路
LlamaIndex里有一条清晰的主线支撑这套设计,叫QueryEngineTool。它允许你把不同的查询能力包装成一个个工具,然后交给LLM去路由选择。
整个链路分五步:
- 用户提问进入主Agent(对话代理)。
- Agent根据问题语义判断需要哪些工具,并给出参数。
- 如果涉及具体商业数据,调用表格检索工具,返回表名、列名、关键行片段。
- 如果涉及数值计算,调用Python执行工具,内部运行pandas代码,拿到精确结果。
- 最后Agent把所有结果汇总,组织成自然语言回答。
这里的关键是第2步的路由能力。它依赖系统提示词里对每个工具的描述写得足够清晰。工具描述就相当于给模型的一份“工具使用说明书”,写得不清楚,模型就会乱选。
2.3 Prompt设计:让模型“不硬算”
很多人以为Prompt设计就是把问题往模型面前一摆。在带工具的场景里,Prompt的核心任务是“约束模型的行为边界”。
我有一段长期在用的系统提示词,核心内容大致如下:
你是一个企业级表格数据分析助手。当用户的问题涉及数值计算(求和、均值、环比、筛选、排序、聚合等)时,你必须调用Python计算工具执行,禁止根据检索片段口头估算。当用户问题只涉及表格结构、字段含义或数据范围时,你可以直接根据检索结果回答。每次计算完成后,你要展示关键中间值和最终结果。
这段提示词解决了两个高频问题:一是模型“偷懒”不调工具,二是模型在只有部分数据时强行回答。
另外,为了让模型能自己选对工具,每个工具的描述里要写清“适用场景”和“不适用场景”。比如表格检索工具描述里写“适用于定位哪个Sheet、哪一列、表头字段说明”,Python工具描述里写“适用于任何需要对数值列进行精确计算的问题”,并在每个描述末尾加一句“不确定时优先调用Python工具”。
3. 实操:从零搭建一个Excel RAG系统
3.1 环境准备与依赖
我用的Python版本是3.10,核心依赖如下:
pip install llama-index pip install llama-index-readers-file pip install pandas openpyxl pip install openaiLlamaIndex的版本迭代比较快,建议安装后先确认当前版本再往下走。我这里实际使用的是0.10.x系列,API和旧版有差异,网上很多教程是旧版写法,照着抄容易报错。
import部分我统一写在下面,后面所有代码片段默认依赖这些导入:
import os import pandas as pd from llama_index.core import ( Settings, VectorStoreIndex, SummaryIndex, Document, StorageContext, load_index_from_storage, ) from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import LLMSingleSelector from llama_index.core.tools import QueryEngineTool, ToolMetadata from llama_index.core.agent import ReActAgent from llama_index.readers.file import PandasExcelReader from llama_index.core.objects import SQLTableNodeMapping, ObjectIndex, SQLTableSchema from llama_index.core.query_pipeline import QueryPipeline注意其中PandasExcelReader在读取复杂Excel时依赖openpyxl,所以openpyxl务必同时安装。
3.2 加载Excel:表格不是文本
加载这一步看起来简单,但选择不同的加载方式直接决定后面能不能算得准。
一开始我试过把Excel转CSV再用文本加载器读,结果多Sheet表格直接炸裂:第二个Sheet的头部信息丢失,日期列被读成字符串,带中文列名的列被转义得乱七八糟。后来我改用PandasExcelReader,它返回的是真实DataFrame对象,后续工具可以直接对DataFrame做操作。
loader = PandasExcelReader() documents = loader.load_data( file="./data/sales_data.xlsx" )这个加载器有一个重要的处理逻辑:每一个Sheet会作为一个独立Document返回,Document的元数据里带着Sheet名称。如果你有多个Sheet,之后的索引要按Sheet名称区分开。
如果不方便引入LlamaIndex的加载器,也可以用pandas原生读取,然后手动包成Document:
df_dict = pd.read_excel("./data/sales_data.xlsx", sheet_name=None) documents = [] for sheet_name, df in df_dict.items(): content = ( f"Sheet名称: {sheet_name}\n" f"列名: {list(df.columns)}\n" f"数据形状: {df.shape}\n" f"前5行样例:\n{df.head().to_string()}" ) documents.append(Document(text=content, metadata={"sheet_name": sheet_name}))这段代码里我把列名、形状、前几行样例放进了Document文本。这些就是用于检索的描述信息,比把整个Excel塞进文本有用得多。明细数据没有进文档,它们留在DataFrame里,等计算工具来查。
3.3 建立索引与检索器
对表格类问题,我建议在同一个数据源上同时建两种索引,交给RouterQueryEngine按需选路。
第一种是向量索引,服务“这张表是干什么的、有哪些字段”这类检索问题。第二种是摘要索引,服务“整体概况、数据范围”这类聚合描述问题。两个索引都基于同一批描述性Document,但查询行为不同。
vector_index = VectorStoreIndex.from_documents(documents) summary_index = SummaryIndex.from_documents(documents) vector_engine = vector_index.as_query_engine(similarity_top_k=3) summary_engine = summary_index.as_query_engine()然后定义两个QueryEngineTool,给Router选择:
query_tools = [ QueryEngineTool( query_engine=vector_engine, metadata=ToolMetadata( name="table_schema_search", description="用于查找用户问题涉及哪个表格、哪个Sheet、哪些字段。", ), ), QueryEngineTool( query_engine=summary_engine, metadata=ToolMetadata( name="table_summary", description="用于回答表格整体概况、数据范围、包含多少行列等问题。", ), ), ] router_engine = RouterQueryEngine( selector=LLMSingleSelector.from_defaults(), query_engine_tools=query_tools, )这里要提醒一句:不要把similarity_top_k调太大。表格描述性文档本来就短,k取3足够,取大了容易把不相关的Sheet内容扯进来。
3.4 挂载计算工具:让模型真正“算数”
这一步是整个系统的灵魂。我用一个函数包装pandas计算逻辑,然后用LlamaIndex的FunctionTool把它暴露给Agent。
from llama_index.core.tools import FunctionTool def run_pandas_query(code: str) -> str: """执行用户指定的pandas代码,返回最终DataFrame的Markdown文本或数值结果。 代码运行环境中已经存在名为df的DataFrame默认变量。 计算必须以print()输出结果,系统会捕获print内容。 """ exec_globals = {"df": df, "pd": pd} output_buffer = StringIO() redirected_stdout = RedirectStdout(output_buffer) with redirected_stdout: try: exec(code, exec_globals) except Exception as e: return f"执行出错: {e}" return output_buffer.getvalue() or "没有输出内容,请检查代码。" pandas_tool = FunctionTool.from_defaults( fn=run_pandas_query, name="pandas_code_executor", description=( "当用户的问题需要对数据进行数学计算时," "你必须使用这个工具。请根据问题生成pandas代码," "例如df.groupby('月份')['销售额'].sum()," "代码中可以访问变量df(当前数据表)。" ), )这个工具的巧妙点在于:它不是把某个写死的指标暴露给模型,而是让模型去“写代码、调函数”,在沙箱里执行。模型负责语义理解和代码生成,pandas负责精确计算。这正是“能算数的RAG”最核心的架构决策。
如果你不想让模型生成任意代码,担心安全风险,可以退一步用text-to-SQL,把计算逻辑转成SQL查询。但这种方式需要你提前把Excel导入数据库,而且对复杂计算的灵活性不如pandas。我自己的项目里,因为数据都是内部可信数据源,开放pandas执行是合算的。
到这里,还需要一个Agent把检索工具和计算工具组合在一起:
agent = ReActAgent.from_tools( tools=[router_engine_as_tool, pandas_tool], llm=llm, verbose=True, )这里有个实践细节:RouterQueryEngine本身也可以包成Tool,让Agent在“查表结构”和“算数字”之间做更高层的路由决策。
3.5 联调验证:带着真实问题跑一遍
用一个真实的模拟数据来验证整个链路。假设我的Excel文件是某贸易公司的季度销售明细表,包含三列:月份、区域、销售额。里面是2023年1月到6月,华南、华北、华东三个区域各6行数据,销售额为随机生成的数值。
第一个测试问题:总销售额是多少?
用户提问进入Agent后,ReActAgent先分析问题,判断这是一个“需要精确求和”的问题,于是调用pandas_code_executor工具,生成的代码大致如下:
df['销售额'].sum()pandas工具执行后返回数值,例如5987234.50,Agent把它包装成最终回答:“2023年1至6月三个区域的总销售额为5,987,234.50元。”
第二个测试问题:哪个区域在二季度的销售额最高?
这次Agent会先调用表结构检索工具,确认表里有区域列和销售额列,然后生成如下代码:
df_q2 = df[df['月份'].between(4, 6)] df_q2.groupby('区域')['销售额'].sum().sort_values(ascending=False)执行结果是一个按区域汇总的DataFrame,Agent比较后回答:“二季度销售额最高的是华东区域,合计1,023,450.00元。”
第三个测试问题:这几列数据分别是什么意思?
这种问题不需要计算,Agent会走table_schema_search路径,从向量索引里找到描述性Document,直接回答列名含义和样例值。
联调过程中我建议开verbose=True,这样能实时看到Agent的思路:它选择了哪个工具,生成了什么代码,拿到了什么结果。如果发现一个计算类问题走了文本检索路径,说明工具描述或Prompt还有问题。
4. 常见问题与排查技巧实录
4.1 表格解析乱码、Sheet丢失、列名带非法字符
症状:加载Excel后,文档数量比Sheet数量少,或者列名变成“Unnamed: 0”,或者中文列名在向量检索时完全搜不到。
排查思路:先检查Excel文件本身是否有合并单元格、空行、多级表头。PandasExcelReader对多级表头比较敏感,如果你的表有二级表头,建议先预处理成扁平列名:
df.columns = [f"{col[0]}_{col[1]}" if isinstance(col, tuple) else col for col in df.columns] df.columns = [str(col).strip().replace(" ", "_").replace("\n", "") for col in df.columns]另外,读取时指定sheet_name=None会让pandas返回一个dict,但有些加载器默认只读第一个Sheet。用之前先确认自己的加载器到底读了哪些Sheet。
4.2 检索不到对应表格或字段
症状:问“B区的数据在哪里”,系统返回“没有找到相关信息”。
排查方法分两步:先看描述性Document的质量,再看检索相似度阈值。
描述性Document里至少要包含Sheet名称、列名、字段说明、数据范围、前几行样例。如果样例行缺失,模型就没法把字段含义和数据对上。我见过很多项目在这个环节偷懒,文档里只写“这是一个Excel文件”,那检索效果肯定差。
相似度阈值的调整也值得花时间。LlamaIndex的similarity_top_k默认为2,如果你表多,要适当调大;但如果文档本身质量不高,调大只会让噪声更多。我一般在3到5之间测试。
另外有个实用技巧:在系统提示词里给Agent列一个“可用工具清单”,并明确写出每个工具擅长的场景。模型在路由时如果知道有“表格结构检索”和“pandas计算”两个工具,选择准确率会高很多。
4.3 计算结果不稳定:模型“偷懒”不调用工具
这可能是所有问题里最常见的。症状表现为:模型面对求和、均值问题,直接根据检索到的文本片段口头估算,不生成pandas代码,结果自然经不起核对。
原因通常是Prompt约束不够强或者工具描述不够显眼。
我试过最有效的做法是在系统提示词里加一段硬性规则:
任何数值型计算结果必须以pandas工具返回值或SQL查询结果为准。 禁止根据检索到的文本片段直接给出数字。 如果一段检索文本中包含数字但你没有执行过计算工具,不能回答该数字。
同时,在pandas工具的Description开头加上“当用户的问题需要进行数学计算时,你必须使用这个工具。如果你不使用本工具,结果一定是错的。”这类强约束描述。LlamaIndex的工具描述就是给LLM看的说明书,语气强制一点没关系。
如果模型还是不走工具,可选的兜底方案是强制工具调用模式。在OpenAI兼容接口下,设置tool_choice="required",让模型每一轮都必须先调用工具再回答。
4.4 精度问题:浮点误差、大额数字丢失
运营团队报过一个号:报表总和与线下Excel透视表有几分钱差异。排查下来就是经典浮点误差。
pandas读取带小数的列,默认float64,多行求和后精度误差会累积。解决方法是读取时指定dtype,或者计算后用round()控制输出精度:
df["销售额"] = df["销售额"].astype(float) result = round(df["销售额"].sum(), 2)更稳妥的方式是用Decimal做金额计算,但性能会受影响,适合数据量不大的场景。
大额数字的“丢失”则常见于int64溢出前兆或千位分隔符被读成字符串。如果Excel里的数字列带了逗号(比如1,234,567),直接读取会变成字符串列,后续求和直接报错。读取时的统一处理:
df["金额"] = df["金额"].astype(str).str.replace(",", "").astype(float)这类问题一定要在联调阶段就建一张“测试问题清单”,把求和、均值、环比、同比、TopN、条件筛选等典型计算全部跑一遍,不要等到上线后被业务方拿真实数据砸出坑。
最后再分享两个小经验
第一,如果项目数据表不止一两张,建议做一层数据字典自动生成。遍历所有Sheet和列,自动生成“表名、字段、含义、类型、样例”的描述文本,作为检索Document。我是在加载Excel后写了一个循环自动生成这套描述,省了手工写文档的功夫,效果比手工写稳定。
第二,系统上线后,把用户实际问过的问题沉淀成评测集。每次改Prompt或模型版本,先跑一遍评测集,看正确率有没有回退。表格问答系统最怕的就是“这次改好了A问题,结果B问题回归”,有了评测集合,心里才有底。
这套“检索加代码执行”的思路,不只是针对Excel。CSV、数据库表、甚至接口返回的JSON数组,都能用同样的逻辑接入。核心始终只有一条:别让大模型心算,让它学会查表和用工具。