大模型写的 SQL,为什么一跑就报错?——一套 RAG 地基,长出「问答 / SQL / 报表」三个能力
第一篇把最小 RAG 链路跑通、第二篇把检索的坑踩透、第三篇把它从"能跑"调成"能用"(混合检索 + 重排序 + 多轮)。这是这个系列的完结篇:同一套检索器,后面换段 prompt,还能长出 Text-to-SQL 和报表解读。 全程还是 Node.js + 原生
fetch。但这一篇真正的主角不是某个新算法,而是一个词:复用。
🎯 本文目标
读完你应该能讲清楚三件事:为什么直接让大模型写 SQL"一跑就报错"、RAG 怎么用一招(grounding)把它治好;报表解读凭什么比普通 BI 多值一份钱,那份钱到底值在哪;以及贯穿全文最值钱的一点——为什么加三个新功能,检索代码一行都不用改。
前三篇讲了什么,这篇收个什么尾
这个系列到这里整整四篇,是一条完整的成长线:
- 第一篇《前端也能搞懂 RAG》 —— 手写最小链路:embedding、余弦相似度、迷你向量库、拒答兜底。解决"RAG 到底是什么"。
- 第二篇《前端手写 RAG 踩坑实录》 —— 接上真实文档后的四个坑:切太碎、切太大、连接被重置、高分≠能回答。
- 第三篇《前端 RAG 工程化》 —— 给检索补第二条腿:混合检索(向量 + BM25 + RRF)、规则重排序、多轮记忆。
- 本篇 —— 前三篇一直在打磨"检索"这一件事。这一篇要证明:这块打磨好的检索地基,价值远不止问答一个出口。
前三篇的概念(RAG 原理、余弦相似度、分块、混合检索)本篇不再重复,涉及处直接给链接。
🧭 一句话交代场景
还是那个数据治理助手:一张订单表 orders,字段口径(amount 含不含税、order_date 记的是下单还是支付时间)散落在建表注释和同事的记忆里。前三篇让它能"回答字段是什么意思"。这一篇,让同一个助手再学会两件事——帮你写查数的 SQL、帮你读懂一份报表。
🧩 第 1 章:一个反直觉的起点——加三个功能,检索代码一行没动
🎯 本章目标
先把这一篇的"总纲"立起来:为什么"问答 / SQL / 报表"看着是三个功能,本质上却是同一个东西。
先别急着写 SQL。这一篇能成立,全靠一个你可能没意识到的事实。
回想第三篇最后那个检索器 searcher,它干的事拆开看就三步:
用户问题 → ① 混合检索粗召回 20 条 → ② 重排序精排出 5 条 → ③ 把这 5 条塞进 prompt 交给 LLM现在关键的问题来了:问答、写 SQL、读报表,这三件事在上面这条链路里,到底哪一步不一样?
答案是——只有第 ③ 步的最后半句"塞进哪个 prompt"不一样,前面①②两步一模一样。
| 功能 | ① 检索 | ② 重排 | ③ 最后那段 prompt |
|---|---|---|---|
| 字段问答 | 一样 | 一样 | "根据资料回答问题" |
| Text-to-SQL | 一样 | 一样 | "根据资料写一条 SQL" |
| 报表解读 | 一样 | 一样 | "根据资料解读这份数据" |
看懂这张表,这一篇就懂了一大半。检索器是地基,prompt 是插头——地基把"从一堆元数据里找到最相关的几条"这件最难、最脏的活儿干完了,剩下的不过是换个插头,告诉 LLM"拿这些料去干哪件事"。
所以下面两章的代码会短得让你意外:因为真正的重活,前三篇早就干完了。
// 三个功能的骨架,长得几乎一样——不同只在最后一行传哪个 prompt
async function 某功能(searcher, question, ...) {
const candidates = await searcher.search(question, 20) // ① 检索:三个功能共用
const hits = rerank(question, candidates, 5) // ② 重排:三个功能共用
return await chat(question, hits, 某个专用Prompt(hits)) // ③ 只有这里换插头
}💡 这才是"工程化"的真正样子
初学者觉得"做三个功能 = 写三套代码"。但一个好地基的价值,恰恰是让新功能变成"换个 prompt"这么廉价。你在面试里能说出"我这三个能力共用一套检索、只有出口 prompt 不同",比罗列"我会 Text-to-SQL、会报表分析"有分量得多——前者证明你有架构判断,后者只是功能清单。
🧭 承上启下
总纲立好了。现在换上第一个"插头"——让这套检索器去写 SQL。它要解决的,是一个几乎人人都撞过的坑。
🗄️ 第 2 章:Text-to-SQL——治好大模型「猜字段名」的病
🎯 本章目标
搞懂为什么裸让大模型写 SQL 会"一跑就报错",以及 RAG 的 grounding 怎么一招治好它。
现象:它写的 SQL 很漂亮,就是跑不了
你让 ChatGPT"帮我写条 SQL,统计最近 30 天每天的销售额",它会秒回一条工整的:
SELECT DATE(create_time) AS day, SUM(sales_amount) AS total
FROM orders
WHERE create_time >= NOW() - INTERVAL 30 DAY
GROUP BY day;语法挑不出毛病。可你把它贴进公司的数据库一跑——报错:Unknown column 'sales_amount'。因为你库里那个字段根本不叫 sales_amount,它叫 amount;"创建时间"也不叫 create_time,叫 order_date。
为什么会这样:模型在"猜",因为它没见过你的库
这不是模型笨。是它压根不知道你的表长什么样。它训练时见过千千万万个数据库,"销售额大概叫 sales_amount、创建时间大概叫 create_time"是它学到的统计习惯。轮到你这张表,它没有任何信息,只能按习惯猜一个最像的名字。
猜出来的字段名语法完全合法、命名也很"专业"——但和你的真实库对不上。这就是大模型写 SQL 最致命的问题:它不会告诉你"我不知道你的字段名",它会一本正经地编一个。 这也正是第二篇坑 4那个主题的翻版:看着对,其实错。
解法:写之前,先把真实字段"喂"给它
病根是"模型不知道真实字段名",那解法就直白了:写 SQL 之前,先用检索把相关表的真实字段捞出来,塞进 prompt,再让它写。 这一步在 RAG 里有个专门的词,叫 grounding(接地)——把模型飘在空中的"猜测",摁到你真实数据这块"地"上。
代码短得几乎没有新东西——检索、重排都是第三篇的原件,新增的只有一段 prompt:
// text-to-sql.js —— 基于检索到的元数据生成参考 SQL(只生成、不执行)
import { chat } from '../chat.js'
import { rerank } from './rerank.js'
import { buildHybridSearcher } from './metadata-qa-hybrid.js'
// ★ 全部的"新东西"就这一段 prompt——把真实字段圈进来,把 LLM 的自由发挥摁死
function buildSqlPrompt(hits) {
const context = hits.map((h, i) => `[元数据${i + 1}] ${h.text}`).join('\n')
return `你是一个数仓 SQL 助手。根据下面的元数据,为用户的问题写一条参考 SQL。
规则:
1. 只能使用元数据里出现过的表名和字段名,绝不臆造不存在的字段。
2. 如果元数据里缺少必要的表或字段,直接回答"元数据不足,无法生成可靠 SQL",不要硬编。
3. 时间范围、状态过滤等关键条件要写清楚。
4. 先输出 SQL(单独成段,方便复制),再用一句话说明你用了哪些表和字段。
可用元数据:
${context}`
}
// 生成参考 SQL:检索 + 重排(第三篇原件)→ 换上 SQL 专用 prompt
export async function generateSQL(searcher, question) {
const candidates = await searcher.search(question, 20) // ① 检索:没改
const hits = rerank(question, candidates, 5) // ② 重排:没改
const sql = await chat(question, hits, buildSqlPrompt(hits)) // ③ 换插头
return { sql, sources: hits }
}跑一下,这次它写出来的是:
SELECT order_date AS day, SUM(amount) AS total_sales
FROM orders
WHERE order_date >= CURDATE() - INTERVAL 30 DAY
GROUP BY order_date;amount、order_date ——全是你库里真实存在的字段。同一个模型、同一个问题,差别只在于:这一次,它写之前"看过"你的表。
为什么这么写,三个要点:
buildSqlPrompt的规则 1、2 是命门。 "只能用元数据里的字段""缺了就说不足"——这两条把模型的"猜"摁死了。没有它俩,Text-to-SQL 立刻退化回"凭习惯猜列名"。规则是把 grounding 从"给了资料"升级成"强制只用资料"的最后一道锁。- 检索复用
search+rerank,和第 1 章那张表完全对上。 Text-to-SQL 不是新链路,是问答链路"换了个出口 prompt"。你甚至能把这个generateSQL和问答的主流程并排放,会发现只有chat那一行的 prompt 不同。 - 返回里带
sources。 前端可以展示"这条 SQL 用到了这些字段",让用户一眼核对——AI 写的 SQL 不该是黑盒,得让人能验。
深一层:为什么是"检索喂字段",而不是微调、也不是把整个库塞进去
到这你可能会问:让模型认识我的库,还有别的路吧?有,但都不如 RAG 划算。对比一下就懂为什么这是标准姿势:
| 做法 | 怎么让模型认识你的库 | 问题 |
|---|---|---|
| 微调一个模型 | 拿你的 schema 去训练 | 贵、慢,字段一改就得重训,杀鸡用牛刀 |
| 把整个库结构塞进 prompt | 每次把所有表所有字段都发过去 | 表一多 prompt 就爆 token,且无关字段是噪声,干扰模型 |
| RAG(本文) | 只检索跟这个问题相关的几张表/字段 | 便宜、实时、精准——问啥取啥,改字段只需重建库 |
RAG 的精髓在中间那列:它不是把整本字典背给模型,而是每次只翻到相关的那一页。 问"销售额"就只捞 orders 和 amount 附近的元数据,其他表根本不进 prompt。省 token、降噪声、还实时——字段口径改了,重建一次库就生效,不用碰模型。这就是为什么"先检索元数据再生成 SQL"是业界落地 Text-to-SQL 的标准姿势,而不是微调。
⚠️ 一条必须守住的红线:只生成,不执行
你注意到没有——从头到尾,这段代码没有连数据库、没有跑这条 SQL。它只把 SQL 当文本生成出来,交给用户自己去执行。这不是偷懒,是故意划的安全边界:
- LLM 生成的 SQL 可能有性能陷阱(全表扫描)、甚至误伤(一个不该有的
DELETE)。让它直连数据库自动执行,等于把方向盘交给一个偶尔会走神的实习生。 - "只生成、把执行权留给人",既拿到了 AI 提效的好处,又把风险锁在了"人点一下确认"这道闸后面。
面试被问到 Text-to-SQL,能主动说出"我只做生成不做执行、执行权留给用户",比只会讲怎么生成,更能体现工程上的分寸感。
🧭 承上启下
SQL 这个插头装好了。换第二个——让同一套检索器去读一份报表。这次要回答一个更"值钱"的问题:你这个 AI 解读,凭什么比公司现成的 BI 工具(Business Intelligence,做数据报表和可视化看板的软件,如 Tableau、Power BI、帆软)强?
📊 第 3 章:报表解读——比普通 BI 多知道一件事:字段口径
🎯 本章目标
搞懂 AI 报表解读的差异化到底在哪——不在"会算涨跌幅",在"懂业务口径"。
现象:普通 BI 只会告诉你"跌了 12%"
假设有这么一段销售数据:
日期 amount
2026-06-01 12000
2026-06-02 11800
2026-06-03 9500
2026-06-04 6200
2026-06-05 6100任何一个 BI 工具、甚至一行 Excel 公式,都能告诉你"从 6-01 到 6-05 跌了约 49%""6-03 到 6-04 有个陡降"。但这些只是把数字重新念了一遍——涨跌幅是纯算术,谁都会,没有信息增量。
差异化:你的助手知道 amount 是"税后金额"
真正拉开差距的是这个:你的助手知道每个字段的业务口径。 它检索得到 orders 的元数据,知道——
amount是税后金额(不是流水,是实际到手);status里的refunded代表退款。
于是它能说出普通 BI 说不出的话:"6-03 起销售额连续下滑,因 amount 是税后口径,这波下跌需警惕是否与退款率上升有关,建议交叉核对 status=refunded 的记录。"
同样一份数字,普通 BI 停在"发生了什么",你的助手能往前迈一步猜"为什么"——因为它懂字段背后的业务含义。 这一步,就是那份"多出来的钱"值的地方。
实现:两路信息一起喂——数据 + 元数据
实现上,就是把两样东西同时交给 LLM:用户粘的报表数据,和检索来的字段口径元数据。代码骨架你已经很熟了——还是检索、重排、换 prompt:
// report-insight.js —— 报表数据 + 字段元数据 一起喂,做"结合口径"的解读
import { chat } from '../chat.js'
import { rerank } from './rerank.js'
import { buildHybridSearcher } from './metadata-qa-hybrid.js'
function buildInsightPrompt(hits, dataText) {
const context = hits.map((h, i) => `[字段元数据${i + 1}] ${h.text}`).join('\n')
return `你是一个数据分析助手。结合字段口径元数据,解读下面这份报表数据。
规则:
1. 先点出关键趋势(涨跌幅、拐点)和明显异常值。
2. 结合元数据解释数字含义——比如某字段是税后金额、某状态代表退款,要在解读里用上。
3. 不要编造元数据里没有的口径,没有就只做数字层面的描述。
4. 控制在 150 字内,先给结论再给依据。
字段口径元数据:
${context}
报表数据:
${dataText}`
}
export async function interpretReport(searcher, question, dataText) {
const candidates = await searcher.search(question, 20) // ① 检索:还是没改
const hits = rerank(question, candidates, 5) // ② 重排:还是没改
const text = await chat(question, hits, buildInsightPrompt(hits, dataText))
return { text, sources: hits }
}为什么这么写,三个要点:
- 元数据放前、数据放后,分两段给。 元数据是"口径字典"(拿来查的参考),数据是"被解读的对象"(拿来分析的正文)。在 prompt 里分成两段、标好名字,LLM 才不会把"参考资料"和"待分析数据"搅在一起。这是喂多路信息时的一个通用小技巧。
- 规则 3 是防线:没口径就只描述数字,别编。 这跟第一篇的"拒答"、第三篇的"元数据不足就说不足"是同一种精神——宁可少说一句,不可瞎说一句。模型有种讨好倾向,会为了"显得自己结合了元数据"而硬编一个口径出来,规则 3 就是摁住这个冲动。
- 检索还是那一套
search+rerank。 数一下——这已经是第三次复用同一个检索器了(问答、SQL、报表)。第 1 章那张表不是空谈,是真的三个功能共用一套地基。
💡 数据太大怎么办(一个能加分的扩展点)
如果用户粘几百行报表,全塞进 prompt 会爆 token。工程上的处理是:先聚合再喂——按天/按类汇总成几十行,或只取"前 N 行 + 整体统计摘要",让 LLM 看浓缩版而不是原始流水。本文的 Demo 数据小,没做这步,但面试时能主动提一句"数据大了我会先聚合降 token",是个体现工程意识的细节。
🧭 承上启下
三个插头都装完了。退一步看这四篇一路走来,会发现真正沉淀下来的资产,其实不是这三个功能本身。
🎁 结语:真正的资产,是那套"地基",不是三个功能
💎 只记一句话也够
这一篇表面在做 Text-to-SQL 和报表解读,内核只讲了一件事:一套打磨好的检索地基,能用"换个 prompt"的成本,不断长出新能力。地基,才是你真正的资产。
把这四篇连起来看,是一条特别清晰的线:
- 第一篇:搭出最小地基(能检索)。
- 第二篇:把地基的坑填平(检索得准)。
- 第三篇:给地基加固(混合检索 + 重排 + 多轮,检索得稳)。
- 这一篇:证明地基的复利——同一套
searcher,接问答是问答,换个 prompt 是 SQL,再换个 prompt 是报表解读。
前三篇花的所有力气,到这一篇开始连本带利地还。你会发现第 2、3 章真正的新代码,各自只有一段 prompt——因为难的、脏的、值钱的活儿(怎么从一堆元数据里精准捞出相关的几条),前三篇早干完了。新功能之所以这么便宜,正是因为地基这么贵。
这就是我最想透过这四篇留给你的东西——它跟具体的 RAG、SQL 都没关系,是一种通用的工程直觉:
先在最难的那件事(这里是"检索准")上把地基打扎实,让后续的每个新需求都退化成"在地基上换个薄薄的出口"。这样你加功能的速度会越来越快,而不是越来越慢。
面试里,比起说"我会 RAG、会 Text-to-SQL、会报表分析"这一串功能清单,你若能说出——
"我做的是一套检索地基,问答、Text-to-SQL、报表解读三个能力共用同一套混合检索 + 重排序,只有最后的出口 prompt 不同。所以加新能力对我来说是换个 prompt 的成本,不是重写一套。"
——高下立判。前者是"我用过很多工具",后者是"我懂怎么把系统搭成能持续生长的样子"。功能会被问到死角,但这种'把重活沉淀成可复用地基'的判断力,换任何技术栈都值钱。
RAG 系列到此收尾。四篇加起来代码也就几百行,没有一处黑魔法。真正稀缺的从来不是"知道有 embedding、有 BM25、有 RRF",而是能把它们组织成一个越用越省力的系统,并讲得清每一步为什么这么搭。 那,才是这四篇真正想给你的。
🚀 还能往哪走
这套地基的复利还没榨干:把三个能力接进前端 Demo,就是一个能演示的"数据治理助手";给报表解读接上第一篇流式输出,解读就能边生成边显示;再往上,让 LLM 自己决定"这个问题该调问答、SQL 还是报表解读",就迈进了 Agent 的门——那又是另一套故事的开始了。
原创声明
本文首发于我的个人博客 https://rjy92.github.io/。如需转载请注明出处。