解决text-to-SQL系统的核心难题——如何将数据库schema压缩进prompt且保持准确,dbctx自动提取表结构、关联、字段语义,供LLM直接理解。
想象一下,一位技术支持工程师在深夜向内部聊天机器人输入了这样一个问题:
“上个月有多少企业客户支付失败?”
聊天机器人使用连接了生产 PostgreSQL 数据库的 LLM。它在几秒钟内写出 SQL 查询、执行查询,并给出带有完整置信度的答案。
这并不是因为模型的 SQL 写得差。它猜测 "failed" 意味着 status = 'failed',而你的 payments 表实际使用的是 state = 'declined'。它还猜测了 "enterprise" 数据所在的位置,而实际上这些数据需要三次 join 才能到达,隐藏在名为 metadata.plan.tier 的 JSONB 字段中。
查询运行没有报错并返回了真实的行数据,所以没有人看到任何警告。有人根据一个根本不正确的数字做出了决策。
如果你尝试过构建这样的系统——一个 text-to-SQL 系统,或一个能回答数据相关问题的 AI 智能体——你就会遇到同样的瓶颈。难点不在于 SQL 生成;现代 LLM 写 SQL 写得很好。难点在于以一种体积足够小(能放进 prompt)、同时又足够准确(值得信赖)的方式,告诉模型你的数据库实际包含什么。
这篇文章介绍 dbctx,一个正是为了解决这个问题的开源 Go 工具。它连接 PostgreSQL、读取真实模式和真实数据,将所有这些编译成一个名为 .dtx 的紧凑文件——AI 系统可以在毫秒级查询它。构建这个索引完全不涉及生成模型。过程是确定性的,所以你构建一次索引,然后可以反复使用。
你的数据库不等于你的模式
如果让一位工程经理描述他们的数据库,你通常会得到一张 ER 图。如果让 LLM 针对同一个数据库写查询,真正进入 prompt 的其实是一个简单的模式转储:
payments(id, org_id, status, created_at, metadata JSONB)
这行内容是准确的,但几乎毫无用处。它列出了五个列名,却没有解释其中实际包含什么。
我们用 dbctx 测试了自己的生产数据库——那就是 LiveReview(我们的 AI 代码评审产品)背后的数据库。这不是一个小型演示模式。它有:
558 个不同的 JSONB 路径
我们将 dbctx 指向一个名为 reviews 的表。普通的模式转储永远不会向你展示这些,但 dbctx 会自动提取出来:

模式声明 status 为 character varying(50)。模式中没有任何地方说明它只能持有四个值,但实际上它确实只能持四个值:completed、failed、created 和 in_progress。dbctx 通过读取实际行数据找到了这些值。
这很关键,因为一个写出 WHERE status = 'success' 的 LLM 并不是在随机幻想。它是在猜测一个模式从未声明过的值。
JSONB 列隐藏得更多。模式转储只显示一行 metadata jsonb,而这一行在我们的 metadata 列中隐藏了 31 个不同的路径:

有一条路径 $.review_result.comments[].Severity 持有值 info、warning 和 critical。另一条路径 $.triggered_from 持有值 frontend。除非你已经知道要去查找,否则你看不到这些,而一个看着 metadata jsonb 的 LLM 根本不可能知道这些路径的存在。
模式描述的是形状,而不是含义。含义恰恰是模型写出可信查询所需要的东西。
外键造成了一个相关问题:它们形成一张图,而扁平的模式转储不会为你遍历那张图。关于 reviews 的问题几乎总是需要来自 pull_requests 和 orgs 的数据,因为 join 的方向指向那里,而无论是一个人还是一个工具,总得有人追踪那条路径。
大小是最后一个问题。我们自己数据库的完整模式,用纯文本写出来,在模型还没看到你的实际问题之前就已经达到数千 token 了。现在放大了看:一个真实的企业系统可能有 500 张表和 6000 列,这样一个系统的完整转储可能达到数万 token,足够在问题到来之前填满大多数现代模型的上下文窗口。
这不只是贵,还严重损害准确性。模型必须在数百张无关的表中搜索才能找到相关的十张表,而每一个多余的 token 都是它失去焦点的机会。
像源代码一样编译它
这里有一个有用的类比,也是名称 dbctx 的由来。
你不会把原始的 .go 源文件交付生产环境,让运行时在每次请求时解析它们。你编译代码一次,产生一个为运行时构建的小型产物,然后你交付的是那个产物而不是源代码。
dbctx 对数据库的处理方式与此相同。它连接 PostgreSQL、读取模式、对数据进行采样、并映射 JSONB 结构。然后它将所有这些编译成一个 .dtx 文件——一个可移植的 SQLite 数据库——这是你的 Postgres 实例的"编译"版本。
你的实时数据库仍是事实来源。.dtx 文件才是你真正交给 AI 系统的东西。这个文件:
以下是从事实时数据库到得到答案的完整路径:

仔细看这张图。文章后面每一次深度探索都会放大其中的一个框。
左边一半是核心索引,仅从确定性自省构建:不涉及 API key、不涉及推理服务器、不涉及生成模型。
右边一半将一个问题转化为一个经排序、连接和裁剪的表集合,这个过程也是确定性的。
LLM 只在两个地方出现:
实际构建了什么
运行 dbctx build 会产生三层理解。每一层都建立在前一层之上。
结构层是任何自省工具都会给你的。派生层是 dbctx 真正发挥作用的地方:知道一个列存在和知道它的实际内容之间是有区别的。检索层使得整个索引在 prompt 时可查询,而不是在每次请求时从头重新分析。
dbctx build postgres://user:pass@localhost/mydb --output mydb.dtx
我们用它针对我们 60 张表、758 列的生产数据库运行了它。完整构建,包括模式提取、字段分析和每张表的 JSONB 分析,大约花了 12 秒——大约和你大声读出这段话的时间一样。
生成的文件是 448 KB,比你手机上的大多数照片还小。你可以毫不犹豫地把它作为附件发到 Slack。
dbctx query mydb.dtx "How many failed GitHub reviews last month?"
输出不是 prose。它是基于紧凑符号的模式,专门为模型阅读而构建的:
--- notation ---
PK: primary key col → table foreign key
^ is primary key ? nullable >target FK target
[state] state-like categorical (< 100 distinct values)
[cat] categorical field
{a, b, c} representative values (from pg_stats)
$.path type {samples} JSONB path with inferred type
(score: X.XX) relevance score from query matching
reviews (score: 15.24)
PK: id
org_id → orgs
pull_request_id → pull_requests
status character varying(50) [state]
{completed, failed, created, in_progress}
metadata jsonb
$.provider string {github, gitlab}
created_at timestamp with time zone
orgs (score: 3.12)
PK: id
name text
plan text [state]
{free, pro, enterprise}
这里发生了两件事。第一,reviews 得分最高,因为 "reviews" 和 "failed" 都直接匹配到它。第二,orgs 出现了,尽管问题中从未提到它。dbctx 自动将它拉进来,因为 reviews.org_id 指向 orgs,而任何真实的查询都需要那个 join。
顶部的图例不是装饰。它告诉下游 LLM 如何准确阅读 [state]、→ 和 $.path,所以你永远不必在 prompt 中解释这种符号。
以下是同一工具、同一数据库、回答另一个问题的结果:

查询是 "billing subscription plan"。dbctx 匹配到 subscriptions 表,得分 3543.59,拉取了它指向 users 和 orgs 的外键,并返回了该表的完整列列表、它的 state 字段(status,带有真实值 created 和 cancelled)以及它的分类字段。
该输出约有 30 列。我们的完整模式有 758 列。dbctx 用不到数据库的 4% 回答了问题。另外 96% 的内容从未被任何东西读取过。
在浏览器中探索索引
dbctx ui mydb.dtx
这条命令启动一个本地 Web 探索器。它展示 dbctx 提取的所有内容,你无需编写任何查询就能检查索引:
上方的概览面板展示了我们自始至终使用的同一个生产数据库。dbctx 完全自动地完成了所有提取工作,没有任何手动文档:
159 个分类字段
7,031 个不同字段值
想象两个人用同一个 schema 向 dbctx 提了不同的问题:"buyers" 和 "customers"。一个问题得到了完美匹配,另一个则一无所获。没有叫 buyers 的表,再多的模糊匹配也无法把 "buyers" 变成 "customers"。字符串相似度无法跨越这道鸿沟。
这就是词法搜索的真实局限,也正是 dbctx 不单纯依赖词法匹配的原因。
对于直接命中,打分机制通过累加证据来实现:查询是否提到了表名、列名、列中存储的代表性值,或 JSONB 路径。每个匹配项都会增加权重,匹配可以叠加。
回想一下之前的例子:对于查询 "billing subscription plan",subscriptions 表得分为 3543.59。表名直接匹配,列名 plan 和 plan_type 有意义,而且查询词同时匹配了多个信号。
打分之后,dbctx 从每个匹配到的表出发,沿着外键图向外扩展。如果 reviews 匹配了,orgs 和 pull_requests 会自动被带入,因为一个需要 reviews 的查询几乎总需要通过它们做连接。

对于 "buyers" 这种情况,单独的词法打分只会返回一堵零分墙。这正是 dbctx 附带了语义层的原因。
语义层的存在就是为了处理上述情况:真实的提问,但用的是你的 schema 中字面上不包含的词汇。
它运行在一个小型本地模型 BGE-small-en-v1.5 上,384 维,约 3300 万参数。作为对比,这比人们通常用来连接数据的语言模型大约小 1000 倍。该模型完全在 CPU 上运行。你不需要 API 密钥、外部推理调用或向量数据库。
模型文件本身约 133 MB,比大多数手游还小。dbctx 在你第一次使用时将其下载到 ~/.dbctx,仅此一次。
在构建时,dbctx 为以下内容写入简短文本摘要:
每个有意义的列(具有状态性质、分类或外键的列,不是每个列)
每个值得注意的 JSONB 路径
dbctx 将每个摘要嵌入为向量。在查询时,它同样将你的问题嵌入,然后与这些向量进行普通的余弦相似度比较。它不使用近似最近邻索引;对于一个 schema 规模的对象集,暴力搜索已经足够快,额外的复杂性并无帮助。
重要的是 dbctx 如何将语义命中与词法命中结合。朴素的方法只是将两个分数相加,但这会让模糊的语义匹配超过精确的标识符命中,这是不合理的。相反,dbctx 采用以下方式融合分数:
final_score = lexical_score + 0.6 × normalized_semantic_score × strongest_lexical_score_in_results
下面用实际数字举例。假设查询在词法上对 orders 得分为 40,是结果集中最强的词法命中。同一个查询在语义上对 orders 得分为 0.9,对 purchases 得分为 0.4(归一化相似度值):
orders: 40 + 0.6 × 0.9 × 40 = 61.6
purchases: 0 + 0.6 × 0.4 × 40 = 9.6

精确的标识符仍然以较大优势胜出,但语义相关的表现在结果中出现了,而不是完全消失。
如果词法搜索什么都没找到怎么办?这就是 "buyers" 对比 "customers" 的情况。这时比例因子回退到 1.0。这不足以让弱语义匹配占主导,但足以让真实匹配浮出水面——在词法搜索完全无能为力的情况下。
这需要多少成本?在 50 表规模、约 90 个嵌入对象的情况下,混合查询在模型已热身的情况下约需 43ms,而纯词法搜索只需 7ms。差额约 36ms,接近 24fps 视频中单帧渲染的时间。将模型加载到内存中约需 240ms,这是每个进程支付一次的成本,不是每次查询都支付。
JSONB 是大多数工具放弃处理的 schema 部分。但这也是人们最关心的字段实际所在的地方:severity、plan tier、feature flags、provider metadata。dbctx 不仅检查列类型,还会打开文档并读取它们。
扫描大数据量 JSONB 列的每一行是浪费的,就像为了了解一个电话号码长什么样而把整本电话簿从头读到尾。所以 dbctx 采用采样策略:
小于 5,000 行的表,直接 LIMIT 50。
更大的表使用 TABLESAMPLE BERNOULLI,采样百分比随表增长而缩小。拥有 100 万行的表采样率约为 0.05%,实际检查约 500 行。

每次采样查询还会过滤掉超过 10KB 的文档,因此一个巨大的异常 blob 不会卡住整个构建过程。工作在四个 goroutine 中并行运行,结果写入单个 SQLite 事务,因此构建永远不会留下半写状态的索引。
对于每个路径,dbctx 还必须判断其类型。这并不总是一目了然的,因为不同的行可能确实存在分歧。dbctx 统计该路径在样本中每种类型出现的次数,并保留最频繁的。例如,如果 $.discount 在 98% 的行中是数字,其余的是字符串(某处有一个散落的 "none" 值),dbctx 就会报告它为数字。
数组有自己独特的表示法。$.review_result.comments[].Severity 表示这个路径存在于数组的每个元素内部,正是你在前两节中看到的真实截图里、在我们自己的 metadata 列中三层深处的形状。
具有 20 个或更少不同值的路径会存储完整值列表。这意味着对平面 status 列有效的 {info, warning, critical} 处理方式,同样适用于 JSON blob 中三层深处的路径。
语义搜索能很好地处理 "buyers" 与 "customers" 的情况,因为通用模型已经无数次见过这两个词以相同方式使用。但它从未见过你团队的内部简写。
也许你的 dashboard 说 "LOC",但你的数据库说的是 lines_of_code。或者你的团队用一个绰号来称呼某个指标,而这个绰号在你的 Slack 工作区之外毫无意义。没有任何在公共文本上训练的嵌入能可靠地跨越这个特定的鸿沟。
因此 dbctx 将术语作为第三个独立信号处理,并且刻意让 LLM 远离工具本身。你保持对过程的控制:
生成一个包含你实际 schema 的提示词。
将它发送给你已经信任的任意模型——Claude、GPT、Gemini,哪个都行,这不重要。
像对话一样处理提示词。
将审查后的 JSON 导回到 dbctx。

dbctx terminology prompt mydb.dtx > terminology-prompt.txt
# 粘贴到你选择的 LLM 中,处理后保存 JSON
dbctx terminology import mydb.dtx terminology.json
[
{
"term": "loc",
"aliases": ["line of code", "lines of code", "source lines of code"],
"targets": ["metrics.loc"]
}
]
dbctx 在导入时根据真实 schema 验证每个映射。虚构的表名会被拒绝;它永远不会被默默信任。
术语是检索元数据,不是 schema 内容。这意味着导入一个包含一千条条目的字典,不会向 dbctx 最终交给 LLM 的上下文添加一个额外的 token。术语只改变哪些表被找到,从不改变描述它们需要多少文本。
大多数系统将"给 LLM 的上下文"视为每次请求时重新组装的东西。dbctx 则将 .dtx 文件视为构建产物,就像你对待编译后的二进制文件或生成的 OpenAPI 规范一样。
.dtx 文件归根结底就是一个普通的 SQLite 文件。这不是一个小细节:意味着所有已经支持 SQLite 的工具——CLI 客户端、GUI 浏览器、语言驱动、diff 工具——都可以直接使用 .dtx 文件,无需任何自定义解析器。你可以:
将其纳入版本管理,与 schema.sql 放在一起
用任意 SQLite 客户端查看
用支持 SQLite 的 diff 工具(如 sqldiff)对比两个版本,因为普通的 git diff 对二进制文件不会产生有用的差异
当 schema 变化时重新构建
最后一点有一个需要诚实说明的注意事项。当前的重新构建会重新处理整个数据库,还没有只重新分析实际发生变化表的增量模式。这在路线图上,但尚未实现。在实践中这还不是问题:我们 758 列的生产数据库完整重新构建大约需要 12 秒。在更大的 schema 上值得关注,因为完整重建会花更长时间。
该格式也是向前兼容的。在 dbctx 支持语义搜索之前构建的 .dtx 文件,在新版本中仍然可以正常打开。只是没有语义索引,查询会回退到纯词法匹配,直到你重新构建文件。
用数字说话的性能
下面的每一个数字都来自我们自己的 60 张表、758 列、97 个外键的生产数据库。没有一个是合成基准。
平均查询耗时约 100ms,大约是你眨眼的时间。文本渲染本身——将匹配的表转换为 LLM 读取的符号格式——耗时远低于 1ms。这 100ms 几乎全部来自全文搜索的工作。
50 张表规模下的语义开销
当模型已经预热在内存中时,此规模的混合查询端到端耗时约 36-43ms。几乎整个差异都来自嵌入调用本身,约 16ms。即使 schema 增长,相似度比较本身仍然很快。
Go 库:真正的集成路径
CLI 是一个薄的、便捷的封装。藏在它下面的 Go 库才是 dbctx 在生产环境中真正该待的地方——直接嵌入你的服务,而不是作为子进程调用。
运行 go get github.com/shrsv/dbctx 获取一个符合 Go 开发者实际使用习惯的 API。
Build 给你一个同步索引。但有些服务无法承受在启动时阻塞 12 秒的 introspection 过程。对于这些服务,BuildAsync 在后台 goroutine 中启动构建,并立即返回一个 Index,外加一个你可以 select 的 channel:
idx, ready, err := dbctx.BuildAsync(ctx, "postgres://localhost/mydb", nil)
if err != nil {
log.Fatal(err)
}
defer idx.Close()
go func() {
<-ready
if err := idx.Err(); err != nil {
log.Printf("dbctx build failed: %v", err)
}
}()
// 在构建完成前发出的查询会简单地阻塞直到就绪
result, _ := idx.Query("failed reviews last month")
fmt.Println(result.Matched().Text())
Open 完全跳过数据库连接,直接从磁盘加载预构建的 .dtx 文件。当文件在 CI 中构建并与二进制文件一起分发时,这很有用。
查询结果不是扁平字符串。它是一个 ResultSet,你可以在渲染为文本之前对其进行过滤:
Matched() 返回带评分的表,以及它们的 FK 扩展连接上下文。
ScoredOnly() 丢弃扩展内容,只返回直接命中。
Include() 和 Exclude() 让你可以手动调优最终选择。
Text() 和 TextRaw() 让你选择是否包含符号图例,当你要将同一个 schema 块重复渲染到系统提示词中、且不想每次都为图例支付 token 开销时很有用。
sel := result.Matched().Exclude("audit_log").Include("plan_catalog")
fmt.Println(sel.TextRaw())
该库还暴露了 Tables()、TableDetail() 和 Stats(),用于在索引之上构建你自己的工具(这三个函数为你之前截图看到的 dbctx ui web 浏览器提供动力),外加 Report() 用于生成可管道传输的纯文本 schema 摘要。
术语也有对应的程序化路径。ImportTerminologyGroups 直接接收 Go 结构体,因此已经在某处定义了领域词汇的服务不需要先通过 JSON 文件来回传递。
有一个设计选择值得特别说明:词法核心完全不带 CGO。可选的语义层确实需要链接 ONNX 运行时,所以 dbctx 将其隔离在 SemanticScorer 接口后面。如果该层加载失败,或者你从未请求过它,检索会回退到纯词法模式,你的服务保持运行。
这是一个深思熟虑的架构决策。你可以今天发布一个轻量级二进制文件,以后再添加语义搜索,而无需触碰系统其余部分消费结果的方式。