Dbctx 这个项目做的事情,可以用一句话概括:把一个 PostgreSQL 数据库编译成紧凑、可查询的上下文文件,供下游程序或大语言模型使用。它的价值不在于提供一个全新的查询引擎,而在于把数据库这张“大表”压缩成一份有结构的快照,让下游系统在不需要连接数据库的情况下,也能理解库里的表结构、表关系和数据特征。
在 AI 应用、数据交付和自动化分析场景里,这个需求非常常见。LLM 不擅长直接查询 PostgreSQL,因为数据库响应体积大、字段语义不明确、关系复杂,直接塞进模型上下文既浪费 token 又容易丢失关键信息。Dbctx 这类工具的思路,是把数据库预先“编译”成紧凑上下文,再用关键词检索、SQL 生成或向量检索去消费它。这篇文章会从概念、环境、最小实现、验证、排错到生产实践,把这条链路完整拆开。
适合阅读这篇文章的读者有两类:一类是正在做 LLM 应用、RAG 或智能问答,需要把 PostgreSQL 业务数据接入模型上下文的开发者;另一类是希望把数据库快照结构化交付给外部系统,又不想暴露完整连接信息的后端工程师。读完可以自己实现一个最小可运行的“数据库上下文编译器”,并知道如何评估它对生产系统的价值。
1. 先理解 Dbctx 要解决的上下文问题
1.1 数据库为什么不能直接当上下文用
很多开发者第一次做 LLM 问答时,会尝试把数据库查询结果直接拼进 Prompt。比如查出订单表的所有行,然后塞给模型让它分析。这种做法的第一个问题,是数据量不可控。
一张用户表可能有一千万行,每行几十个字段。即使只取一部分,传输和解析成本也会非常高。更重要的是,模型未必需要全部原始记录。它需要的是“这些数据大概是做什么的、有哪些字段、字段之间有什么关系、数据分布长什么样”,而不是每一行的完整内容。
第二个问题是原始数据库结果缺少语义。数据库里常见status int、created_at timestamptz、product_id integer这样的字段。开发者和模型看到status=3,并不知道 3 代表“已发货”还是“已取消”。上下文如果只保留数据库返回值,不保留字段注释、枚举含义和业务规则,模型就很难给出准确回答。
第三个问题是耦合。让下游程序直接连接生产数据库,意味着它要持有账号、密码、网络白名单权限,还要面对复杂 SQL 注入风险、大查询拖垮数据库的风险。如果只是给模型或分析系统提供一份只读上下文快照,连接耦合就可以彻底解除。
1.2 “编译成紧凑可查询上下文”到底意味着什么
Dbctx 标题里用了 “Compile” 这个词。它不是传统意义上把高级语言翻译成机器码的编译,而是把数据库这张“动态系统”固化成一个“静态知识文件”。编译过程通常包括四件事:结构提取、内容压缩、关系索引和格式导出。
结构提取,是把每个表的字段名、字段类型、是否为空、默认值、主外键关系抓出来。内容压缩,是对数据做采样、聚合、截断,只保留最能描述数据特征的记录。关系索引,是把外键关联、常用查询路径标注出来,方便下游知道表与表之间怎么连接。格式导出,是生成 JSON、JSON Lines、Markdown、SQLite 或向量索引文件,供不同场景消费。
“紧凑”这个词很关键。它意味着输出文件必须比原始数据库小几个数量级。数据库可能几个 GB,上下文文件应该控制在几十 KB 到几 MB。否则就失去了“编译后上下文”的意义,和直接导出全量数据没有区别。
“可查询”意味着上下文不是盲盒。下游程序可以用关键词定位相关表,用结构化字段过滤结果,或者通过语义向量召回相关内容。没有查询能力的上下文文件,只是一个难以使用的转储文件。
1.3 Dbctx 在真实开发链条中的位置
在一套典型的 LLM 应用架构里,Dbctx 可以出现在两条链路中。
第一条是离线分析链路。运营人员想快速了解数据库里有哪些表、表里有多少数据、最近新增了哪些记录。这时 Dbctx 生成的 Markdown 摘要可以直接作为分析报告的输入,不需要每次现写 SQL。
第二条是在线问答链路。用户问“最近一周哪些商品缺货”,系统先用上下文检索找到products表和stock字段的定义,再结合自然语言生成 SQL,或者直接把相关字段和采样数据注入 Prompt,让模型基于真实结构回答。
Dbctx 解决的是这两条链路里共同的痛点:数据库结构、数据特征和语义信息,不能一直只存在于 DBA 的脑子里或散落在不同 SQL 脚本中。它应该被编译成一份稳定、可版本化、可缓存、可审计的上下文产物。
| 链路 | 输入 | 输出 | Dbctx 的辅助作用 |
|---|---|---|---|
| 离线分析 | 数据库依赖 | 摘要报告 | 生成结构摘要和采样数据 |
| LLM 问答 | 用户问题 | SQL 或自然语言回答 | 提供表结构、字段语义和关系说明 |
| 数据交付 | 内部库表 | 外部系统快照 | 脱敏、采样、限定字段范围 |
| 自动化工具 | 多环境数据库 | 统一上下文文件 | 统一不同库的结构表示 |
2. 环境准备:先有一个能跑的 PostgreSQL 实例
2.1 本机安装 PostgreSQL 的几种方式
要理解 Dbctx,不一定需要先安装它。但你需要一个 PostgreSQL 实例来观察“数据库到上下文”的转换过程。安装方式取决于操作系统,这里给出三种常见路径。
第一种是包管理器安装。Ubuntu 和 Debian 上可以使用apt,macOS 上可以使用 Homebrew。Windows 上建议直接使用官方安装器,安装时记得勾选psql命令行工具和 pgAdmin。
# Ubuntu / Debian sudo apt update sudo apt install postgresql postgresql-client # macOS 使用 Homebrew brew install postgresql@15第二种是 Docker 方式。这种方式最干净,不会污染宿主机,也容易清理。如果你的机器上已经装了 Docker,可以直接运行一个容器作为测试库。
docker run --name dbctx-postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=dbctx_demo \ -p 5432:5432 \ -d postgres:15第三种是云数据库。阿里云 RDS、腾讯云 PostgreSQL、AWS RDS 等都可以创建一个测试实例。使用云数据库时,要注意网络白名单和连接地址,本地开发机需要把公网 IP 加入白名单。
需要特别注意版本问题。Dbctx 及其类似工具对 PostgreSQL 的元数据查询依赖information_schema或pg_catalog,这两个系统 schema 在 PostgreSQL 9.6 到 16 之间变化不大,但细节会有差异。学习阶段建议使用 PostgreSQL 14 或 15,避免太老或太新的版本带来额外变量。
2.2 启动、停止和连接检查
安装完成后,第一个要确认的事情不是建表,而是服务能不能正常启动。不同系统的启动命令差异很大,下面是常见做法。
Docker 启动非常简单,不需要 systemd。
docker start dbctx-postgres docker stop dbctx-postgres docker logs -f dbctx-postgres如果是本机安装,Ubuntu 上通常使用 systemd 管理。
sudo systemctl start postgresql sudo systemctl status postgresql sudo systemctl restart postgresqlmacOS 上如果使用 Homebrew 安装,可以用brew services管理。
brew services start postgresql@15 brew services stop postgresql@15启动之后,用psql做一次最小连接检查。这里要理解localhost和127.0.0.1的区别:localhost可能走 Unix socket,也可能走 IPv6,127.0.0.1明确走 TCP。很多连接失败其实是 socket 和 TCP 认证策略不同造成的。
psql "postgresql://postgres:postgres@127.0.0.1:5432/dbctx_demo" -c "SELECT version();"如果看到 PostgreSQL 版本信息,说明服务正常。如果提示password authentication failed,说明密码不对或认证方式配置有问题,需要在 PostgreSQL 的pg_hba.conf里确认127.0.0.1/32的认证方式。
2.3 准备测试表和测试数据
为了后面演示编译效果,建议建两张有外键关系的小表。一张是商品表,一张是订单表。下面的 SQL 可以直接粘贴到psql里执行。
CREATE TABLE products ( product_id serial PRIMARY KEY, name text NOT NULL, category text NOT NULL, price numeric(10,2) NOT NULL, stock integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE orders ( order_id serial PRIMARY KEY, product_id integer NOT NULL REFERENCES products(product_id), quantity integer NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO products (name, category, price, stock) VALUES ('机械键盘', '外设', 399.00, 120), ('无线鼠标', '外设', 89.00, 300), ('27寸显示器', '显示设备', 1499.00, 45), ('USB扩展坞', '配件', 129.00, 0), ('笔记本支架', '配件', 79.00, 210); INSERT INTO orders (product_id, quantity, status, created_at) VALUES (1, 2, '已付款', now() - interval '1 day'), (2, 5, '已发货', now() - interval '3 hours'), (3, 1, '待支付', now() - interval '30 minutes'), (1, 1, '已完成', now() - interval '10 days'), (4, 3, '已取消', now() - interval '2 days');为什么要选外键关系?因为上下文文件里如果能体现orders.product_id -> products.product_id这种关系,LLM 或分析师才能正确理解多表 join 的方向。如果只导出独立的表,上下文会丢失关系语义,查询质量会明显下降。
3. 设计 Dbctx 的编译流程:从数据库到上下文文件
3.1 编译流程的阶段划分
一个可靠的数据库上下文编译器,通常不是一次查询就能完成的。它的流程可以拆成四个阶段:发现元数据、读取统计信息、采样业务数据、组装输出文件。
发现元数据,是查询information_schema或pg_catalog拿到表清单、字段清单、约束和索引。这一步的目的是理解库的“骨架”。读取统计信息,是获取行数、每列是否为空、值分布范围等。统计信息让下游知道数据规模,而不用打开每一个字段。采样业务数据,是挑选几行有代表性的记录,让下游看到真实数据长什么样。组装输出,是把这些内容序列化成目标格式。
这个流程有一个重要原则:不同阶段之间应该是松耦合的。如果元数据采集失败,不应该影响已经生成的上下文文件。如果采样表不存在,应该记录告警而不是让整个编译崩溃。否则在生产环境里,一个表结构变化就能让整个上下文生成任务失败。
3.2 Schema 提取与关系摘要
Schema 提取是最核心的一步。它需要拿到的信息包括:表名、字段名、字段类型、是否可空、默认值、主键、外键、唯一约束和索引。
字段类型值得特别说明。PostgreSQL 的numeric(10,2)和integer虽然都是数字,但语义完全不同。前者表示精确到两位小数的金额,后者表示整数 ID。如果上下文里只保留number,模型就分不清到底哪个是金额、哪个是数量。所以类型不能简化。
外键关系更是如此。没有外键说明,模型可能把orders.product_id当作商品名称或订单编号。有了关系说明,它才知道要 join 到products表的product_id。
下面是一个典型的 schema 摘要,可以从 PostgreSQL 的information_schema中查询出来。
SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public';在输出上下文时,可以把外键关系单独放置,形成类似下面的简写结构:
{ "table": "orders", "column": "product_id", "references": { "table": "products", "column": "product_id" } }3.3 数据采样、聚合与压缩
光有 schema 还不够,下游需要知道数据长什么样。最直接的方式是采样。比如每张表取前 5 行到 10 行记录,展示真实值。
采样需要处理两个问题。第一个问题是选择哪些列。数据库中经常有content text、description text、avatar_url varchar这类大字段,直接采进上下文会让文件膨胀。做法是限制字段最大长度,或者干脆排除指定的列。
第二个问题是敏感字段。用户表里的手机号、邮箱、身份证号,订单表里的支付单号,都不应该进入上下文快照。采样之前应该先做列名单过滤,而不是采完之后再脱敏。列不在上下文中,泄漏风险就少一层。
聚合信息也很有价值。比如行数、空值数量、最小值、最大值、最常见值。对分类字段来说,常见值其实就是枚举语义。例如status字段最常见的值是“已发货”“待支付”“已取消”,下游一看就能理解这个字段的业务含义。
products: 5 rows - category 最常见值: 外设(2), 配件(2), 显示设备(1) - stock 最小 0, 最大 300 orders: 5 rows - status 最常见值: 已付款(1), 已发货(1), 待支付(1), 已完成(1), 已取消(1)3.4 输出格式:JSON Lines 与 Markdown 摘要
上下文的输出格式取决于消费方。主流的做法是双输出:一份结构化文件,一份人类可读文件。
JSON Lines 适合程序解析。每行一个 JSON 对象,对应一张表的完整描述。程序可以用json.loads逐行读取,不需要一次性把整个文件载入内存。这种格式对关键词检索、向量化、SQL 生成器都非常友好。
Markdown 适合给 LLM 当 Prompt 前缀。模型对 Markdown 表格和列表的理解通常比对 JSON 更好,因为层级结构更清晰。下面是一个 Markdown 摘要的例子:
# Database Context: dbctx_demo Generated: 2025-01-10T12:00:00Z ## products - row_count: 5 - product_id: integer - name: text, nullable no - category: text, nullable no - price: numeric(10,2), nullable no - stock: integer, default 0 - created_at: timestamptz ## orders - row_count: 5 - order_id: integer - product_id: integer, references products.product_id - quantity: integer - status: text - created_at: timestamptz两种格式可以同时生成,因为它们来自同一份内存数据结构。生成代码只需要写一次数据组装逻辑,再分别序列化到两种目标文件即可。
4. 用 Python 实现一个最小 Dbctx 编译示例
4.1 项目结构与依赖
这里用一个最小 Python 项目演示整个思路,文件名取dbctx-lite。它不代表 Dbctx 官方实现,而是帮助理解核心设计。实际项目接入时,请以你要使用的版本的文档和 CLI 行为为准。
项目结构如下:
dbctx-lite/ ├── requirements.txt ├── compile_db.py └── query_ctx.py依赖只有 psycopg。推荐使用 psycopg 3.x 版本,它的连接方式和现代 Python 风格更一致。
psycopg[binary]>=3.1安装依赖:
pip install -r requirements.txt环境变量或命令行参数用来传入数据库连接串。不要把密码写死在代码里,尤其是要提交到仓库的示例代码。
4.2 读取数据库元数据
第一个函数负责拿到所有表名。这里查询information_schema.tables,只取BASE TABLE,不取视图,避免把视图和物化视图混在一起。
def fetch_tables(conn, schema="public"): with conn.cursor() as cur: cur.execute(""" SELECT table_name FROM information_schema.tables WHERE table_schema = %s AND table_type = 'BASE TABLE' ORDER BY table_name """, (schema,)) return [row[0] for row in cur.fetchall()]第二个函数读取指定表的字段信息。ordinal_position保证字段顺序和建表顺序一致,这点在生成上下文时很重要。
def fetch_columns(conn, schema, table): with conn.cursor() as cur: cur.execute(""" SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position """, (schema, table)) return [ { "name": row[0], "type": row[1], "nullable": row[2] == "YES", "default": row[3], } for row in cur.fetchall() ]关键点是,不要用SELECT *去推断字段。information_schema本身就是 PostgreSQL 提供的标准元数据视图,准确且稳定。生产环境如果追求性能,可以改查pg_attribute、pg_class、pg_namespace这些系统目录,但示例阶段用标准视图更不容易出错。
4.3 采样数据并生成紧凑上下文
拿到字段之后,可以查询每张表的行数和采样数据。这里要小心两件事:一是表名拼接要校验,二是大字段要截断。
import json import re def safe_identifier(ident): if not re.fullmatch(r"[a-z_][a-z0-9_]*", ident): raise ValueError(f"invalid identifier: {ident}") return ident def fetch_count(conn, schema, table): with conn.cursor() as cur: cur.execute( f'SELECT count(*) FROM {safe_identifier(schema)}.{safe_identifier(table)}' ) return cur.fetchone()[0] def fetch_sample(conn, schema, table, limit=5, max_text_length=200): with conn.cursor() as cur: cur.execute( f'SELECT * FROM {safe_identifier(schema)}.{safe_identifier(table)} LIMIT %s', (limit,), ) column_names = [desc[0] for desc in cur.description] rows = [] for raw in cur.fetchall(): row = {} for name, value in zip(column_names, raw): if hasattr(value, "isoformat"): value = value.isoformat() elif isinstance(value, str) and len(value) > max_text_length: value = value[:max_text_length] + "..." row[name] = value rows.append(row) return rows在拼接表名时,safe_identifier的作用是阻止 SQL 注入。生产环境更好的做法是使用psycopg.sql.Identifier进行标识符转义,而不是用正则白名单。这里为了代码简洁,先用正则做一层约束。
接下来把这些信息组装成一张表的上下文记录,并写入 JSON Lines 文件。
def compile_table(conn, schema, table, sample_size, out_fp): columns = fetch_columns(conn, schema, table) row_count = fetch_count(conn, schema, table) sample = fetch_sample(conn, schema, table, sample_size) record = { "table": table, "schema": schema, "row_count": row_count, "columns": columns, "sample": sample, } out_fp.write(json.dumps(record, ensure_ascii=False) + "\n") return recordensure_ascii=False是关键配置。如果数据库里存了中文商品名,不关闭 ASCII 转义的话,JSON 里会是\u673a\u68b0\u952e\u76d8,阅读和模型理解都变得困难。
主函数负责连接数据库、遍历所有表、生成上下文:
def main(): import argparse parser = argparse.ArgumentParser() parser.add_argument("--db-url", required=True) parser.add_argument("--schema", default="public") parser.add_argument("--sample-size", type=int, default=5) parser.add_argument("--out", default="context.jsonl") args = parser.parse_args() with psycopg.connect(args.db_url) as conn: tables = fetch_tables(conn, args.schema) with open(args.out, "w", encoding="utf-8") as f: for table in tables: compile_table(conn, args.schema, table, args.sample_size, f) print(f"compiled {len(tables)} tables -> {args.out}") if __name__ == "__main__": main()运行命令:
python compile_db.py \ --db-url "postgresql://dbctx_user:dbctx_pass@127.0.0.1:5432/dbctx_demo" \ --sample-size 5 \ --out context.jsonl4.4 增加一个简单的查询入口
编译后的 JSONL 如果没有查询入口,就只能手工翻文件。下面写一个最小关键词检索脚本。它逐行读取 JSONL,把整行记录转成 JSON 字符串,再判断关键词是否在其中。
import argparse import json def search_keyword(ctx_path, keyword, limit=10): hits = [] keyword_lower = keyword.lower() with open(ctx_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue record = json.loads(line) blob = json.dumps(record, ensure_ascii=False).lower() if keyword_lower in blob: hits.append(record) if len(hits) >= limit: break return hits def main(): parser = argparse.ArgumentParser() parser.add_argument("--file", default="context.jsonl") parser.add_argument("--keyword", required=True) args = parser.parse_args() hits = search_keyword(args.file, args.keyword) for record in hits: print(f"{record['table']} ({record['row_count']} rows)") for col in record["columns"]: print(f" - {col['name']}: {col['type']}, nullable={col['nullable']}") print(f"total: {len(hits)} table(s)") if __name__ == "__main__": main()运行:
python query_ctx.py --file context.jsonl --keyword "category"这个脚本只处理精确关键词。生产系统如果要做模糊匹配或语义召回,可以把 JSONL 内容向量化后存入 pgvector,再做相似度搜索。后面会有专门说明。
5. 运行验证:检查上下文质量与查询结果
5.1 编译后文件长什么样
正常运行后,context.jsonl里应该有两行,分别是products和orders的上下文。第一行对应的 JSON 大致如下,这里只展示结构,不展示完整数据。
{ "table": "products", "schema": "public", "row_count": 5, "columns": [ {"name": "product_id", "type": "integer", "nullable": false, "default": "nextval('products_product_id_seq'::regclass)"}, {"name": "name", "type": "text", "nullable": false, "default": null}, {"name": "category", "type": "text", "nullable": false, "default": null}, {"name": "price", "type": "numeric(10,2)", "nullable": false, "default": null}, {"name": "stock", "type": "integer", "nullable": false, "default": "0"}, {"name": "created_at", "type": "timestamptz", "nullable": false, "default": "now()"} ], "sample": [ { "product_id": 1, "name": "机械键盘", "category": "外设", "price": "399.00", "stock": 120, "created_at": "2025-01-09T10:00:00+00:00" } ] }注意几个细节。price在 JSON 中变成了字符串"399.00",这是因为 psycopg 返回的numeric是Decimal类型,而json.dumps默认不能直接序列化Decimal。示例代码里没有专门转换Decimal,实际运行中有可能需要加一层str()转换。这个很容易踩坑,后面会专门说。
created_at已经转换成 ISO 格式字符串。这样即使离开了 PostgreSQL 会话,下游系统仍然能理解时间语义。
5.2 用关键词和 SQL 双层查询
上下文文件生成后,可以用两种方式验证它“可查询”。第一种是上面的 JSON 关键词检索。第二种更适合验证结构是否完整,方法是把context.jsonl重新载入内存,检查是否存在外键关系、字段数量是否完整、采样记录是否包含业务关键字。
import json with open("context.jsonl", encoding="utf-8") as f: records = [json.loads(line) for line in f] tables = {r["table"] for r in records} print("tables:", tables) for record in records: print(f"{record['table']}: {len(record['columns'])} columns, {record['row_count']} rows, sample={len(record['sample'])}")正常输出应该类似:
tables: {'products', 'orders'} products: 6 columns, 5 rows, sample=5 orders: 5 columns, 5 rows, sample=5如果发现sample是空的,而如果表里明明有数据,说明查询用户可能没有权限访问表内容,或者采样逻辑被授权遮断了。
5.3 在 LLM 场景下注入上下文的两种方式
上下文文件最有价值的消费方是 LLM。常见注入方式有两种:全量注入和检索后注入。
全量注入适合小库。把 Markdown 摘要直接放在 Prompt 前面,让模型在回答时参考。例如:
你是一个数据库分析助手。下面是数据库上下文: # Database Context: dbctx_demo ## products - row_count: 5 - category: text, 常见值 外设、配件、显示设备 - price: numeric(10,2) - stock: integer 请根据上下文回答用户问题。检索后注入适合大库。先用query_ctx.py这类脚本从 JSONL 中命中与问题相关的表,再只把相关表的上下文拼接进 Prompt。这样可以控制 token 数量,不丢失关键结构。
无论哪种方式,都要记住一个原则:上下文是给模型看的参考信息,不是让模型盲目相信的权威数据。模型仍然可能犯错,尤其是当采样数据不能代表全量数据时。所以在线回答类应用最好同时返回它参考了哪个表、哪个字段,方便人工校验。
6. 核心参数与取舍:紧凑度、保真度、查询成本
6.1 上下文编译器的关键参数
在实际使用 Dbctx 或自己实现编译器时,参数设计决定了产物质量。下面是一些常见参数,具体默认值以你使用的项目 README 为准。
| 参数 | 含义 | 调小的影响 | 调大的影响 | 建议 |
|---|---|---|---|---|
sample_size | 每张表采样行数 | 文件更小,但可能错过代表值 | 更贴近真实分布,但体积变大 | 小表 5 到 10 行,大表按比例降低 |
max_text_length | 单个文本字段最大保留长度 | 上下文更紧凑,但丢失长文本内容 | 保留信息更完整,但 token 消耗高 | 200 到 500 字符之间 |
include_data | 是否保留采样数据 | 只保留 schema 和统计 | 提供真实数据示例 | 学习环境可开,生产环境视敏感程度 |
exclude_schemas | 排除的 schema 列表 | 可能漏掉业务表 | 上下文更全但噪声更多 | 默认排除pg_catalog、information_schema |
include_views | 是否导出视图 | 忽略视图逻辑 | 覆盖更完整,但视图有时涉及权限 | 按业务需求决定 |
token_budget | 上下文总 token 预估上限 | 更紧凑但可能不完整 | 更完整但超过模型窗口 | 根据模型窗口大小设置,留 20% 余量 |
6.2 紧凑度与保真度如何平衡
紧凑和保真在上下文编译里是天然矛盾的。想要上下文小,就要少采样、短字段、少关系。想要模型回答准确,就要保留更多字段含义、枚举值和关系结构。
关键在于“识别什么是保真的核心”。对大多数库来说,字段名、字段类型、外键关系和常见枚举值是核心。这四样信息量不大,但对回答准确率影响极大。可以先保证这些完整,再根据模型窗口剩余空间决定采样多少行原始数据。
有些列可以彻底丢弃。比如内部的自增 ID、审计时间戳、二进制内容、临时标记位。丢弃前要考虑下游是否真的需要。如果下游要生成 SQL join,主键和关联字段就不能丢。
6.3 采样策略与枚举语义
采样不是简单的LIMIT 5。LIMIT取的是物理顺序的前几行,往往不能代表数据分布。更好的做法是分类采样,比如按枚举字段分组后,每组取几行。
SELECT * FROM products WHERE category = '外设' LIMIT 3;这样能让上下文覆盖不同品类,而不是只看到同一个分类下的商品。如果后续接入 LLM 生成 SQL,这种采样方式会让模型更容易理解category字段的可选值。
从 PostgreSQL 视角看,频繁换组采样会带来额外查询开销。编译任务是低频离线任务,可以接受。但如果是在线注入上下文,应该直接使用缓存快照,不要每次实时采样。
7. 常见问题与排查链路
7.1 连接失败、权限不足和元数据为空
连接失败是最常见的第一个坑。现象通常是psycopg.OperationalError: connection failed或FATAL: password authentication failed。排查顺序是:先确认端口和主机是否连通,再检查密码和账号,再检查pg_hba.conf认证方式。
pg_isready -h 127.0.0.1 -p 5432如果想确认用户是否能看到表,可以先手动查询元数据视图:
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';如果这条 SQL 返回空,很可能是连接用户没有USAGE权限,或者连错了数据库。要注意,连接串里不写数据库名时,psycopg 会默认连接到用户名同名的数据库,这很容易导致元数据为空。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 连接超时 | 端口未开放或防火墙拦截 | telnet 127.0.0.1 5432 | 检查云安全组或本机防火墙 |
| password authentication failed | 密码错误或认证方式为 peer | 查看pg_hba.conf | 改用 MD5/scram 认证 |
| 元数据正常但 SELECT 报权限错误 | 用户只有元数据权限,没有表数据权限 | 用同一账号执行SELECT * FROM products LIMIT 1 | 给用户授权目标表 SELECT |
| 表数量为 0 | 连错了 database 或 schema | 执行SELECT current_database(), current_schema() | 修改连接串和 search_path |
7.2 Decimal、时间类型和中文 JSON 序列化
运行 Python 示例时,如果采样字段包含numeric类型,会看到这样的报错:
TypeError: Object of type Decimal is not JSON serializable原因很简单:json.dumps不认识Decimal。解决办法是在序列化前对值做转换。可以在fetch_sample里对每个值判断类型,也可以写一个默认转换函数。
def default_serializer(value): if hasattr(value, "isoformat"): return value.isoformat() if isinstance(value, Decimal): return str(value) raise TypeError(f"not serializable: {type(value)}")调用时传入:
json.dumps(record, ensure_ascii=False, default=default_serializer)中文乱码问题通常来自两个地方。一个是 JSON 文件写入时没有指定encoding="utf-8";另一个是json.dumps默认开启ensure_ascii=True,会把中文转成\u转义序列。写入时指定 UTF-8,序列化时指定ensure_ascii=False,就能解决。
7.3 编译结果超过模型窗口
编译后的上下文文件可能远大于预期。常见原因有三个:采样行数过多、长文本字段没有截断、表数量太多。出现这个情况时,第一步不是调参,而是看文件里哪张表占用的体积最大。
可以简单统计每行 JSON 的字符数:
import json with open("context.jsonl", encoding="utf-8") as f: for line in f: record = json.loads(line) size = len(line) print(record["table"], size)找到体积最大的表后,再决定是减少sample_size、排除大字段,还是对长文本做更激进的截断。这里要注意,上下文大小不是唯一的评判标准。如果模型需要根据具体内容回答,过度压缩反而会导致答非所问。
7.4 敏感数据泄漏风险
把数据库编译成上下文文件,本质上是一次数据导出。手机号、身份证号、地址、支付信息这些敏感字段,如果原样进入上下文文件,一旦文件被共享或提交到仓库,就构成数据泄漏。
最稳妥的策略是在编译前就排除敏感列。可以在编译脚本里维护一份“排除字段名单”。
exclude_columns = { "users": {"phone", "email", "id_card"}, "orders": {"payment_method", "card_no"}, }字段不在上下文里,后面所有环节都不需要担心它。如果确实需要展示脱敏后的数据,可以在采样阶段直接替换字符串:
value = value[:3] + "****" + value[-2:] if len(value) > 8 else "****"但脱敏逻辑容易出错,特别是当数据格式不统一时。推荐做法是默认排除,只有显式确认的安全字段才允许进入上下文。
7.5 数据库结构变更后上下文过期
编译出来的上下文文件是静态快照。数据库结构一旦变化,旧的上下文不会自动更新。工程上要用版本号来标识每次快照。
{ "snapshot_version": "2025-01-10T12:00:00Z", "db_version": "PostgreSQL 15.6", "generator_version": "dbctx-lite/0.1.0" }下游系统消费上下文时,要先读版本号,再决定是否使用缓存。如果发现快照版本早于某个关键迁移时间,就触发重新编译。
结构变更的典型现象是:模型生成的 SQL 引用了已经不存在的字段,或者描述的是旧表结构。排查时对比最新数据库 schema 和上下文文件里的字段差异即可。
7.6 pgvector:从关键词检索走向语义检索
如果上下文文件里的表很多,关键词检索容易漏掉同义表达。例如用户问“键盘缺货吗”,关键词检索可能命中name字段里的“键盘”,但不会命中描述键盘的“输入设备”。这时可以引入 pgvector,把上下文内容向量化。
方向是:先把 JSONL 里的表描述、字段描述、采样记录拼成文本,用 embedding 模型编码成向量,再存到 PostgreSQL 的向量列里。检索时对用户问题同样做向量编码,查询最近的上下文片段。
CREATE EXTENSION IF NOT EXISTS vector; ALTER TABLE context_snapshot ADD COLUMN embedding vector(384);向量维度取决于 embedding 模型。有的模型输出 384 维,有的输出 1536 维,要按模型文档配置。需要注意,pgvector 只是存储和检索向量,它并不负责生成向量。生成向量的 embedding 服务是另一个独立的依赖。
语义检索能提升召回率,但会引入模型服务、向量维度、索引参数等额外复杂度。生产环境建议先跑通关键词检索和 SQL 生成,确认上下文质量没有大问题后再引入向量化。
8. 生产环境最佳实践与扩展方向
8.1 学习环境最小化,生产环境做隔离
学习阶段可以直接连接主库,读元数据和采样表。生产环境不应该这样。
生产环境的建议是:创建独立的只读账号,只授权目标 schema 的SELECT权限;使用网络隔离,让编译任务运行在数据源同一内网;输出文件不要落在共享目录,控制在特定服务账号可见。上下文文件里不包含数据库密码,但文件本身仍然是敏感数据,访问权限要按数据导出件管理。
如果编译任务要定时运行,建议把输出文件名带上时间戳,保留最近几个版本。这样新版本出问题时可以快速回滚到旧上下文,而不是重新从数据库拉全量。
| 环境 | 数据库账号 | 输出文件 | 刷新策略 | 安全要求 |
|---|---|---|---|---|
| 学习环境 | 当前用户或管理员 | 任意本地目录 | 每次手动编译 | 低 |
| 测试环境 | 只读业务账号 | 测试服务器 | 每次发布前编译 | 不包含真实用户隐私数据 |
| 生产环境 | 最小只读账号 | 安全存储或对象存储 | 定时 + 版本化 | 敏感字段排除、访问审计、权限收敛 |
8.2 发布前检查清单
在把数据库上下文接入 LLM 应用或交付给下游系统之前,建议按这份清单逐项检查。
- 上下文文件里是否包含敏感字段,是否已经排除或脱敏。
- 每张表的字段名和类型是否与当前数据库一致,有没有过期的表或列。
- 外键关系是否完整,关键 join 路径是否在上下文中有说明。
- 采样数据是否覆盖主要枚举值,至少包含表中最常见的分类。
- 文件总大小是否在模型窗口或下游解析能力内。
- 时间字段是否统一成 ISO 格式,数值字段是否序列化正确。
- 文件是否有版本号,是否能够追溯到生成时间、数据库版本和生成器版本。
- 下游程序消费上下文时是否有异常分支,比如表不存在、字段类型变化。
- 该上下文文件的访问权限是否收敛,是否会被误提交到代码仓库。
8.3 扩展方向:增量编译、缓存和审计
当前文章里的示例是全量编译,每次跑都把整个 schema 读一遍。如果数据库有几百张表,全量编译会比较耗时。可以改成增量方式:记录上次编译时间,只导出information_schema中被修改过的表。
但增量编译也有风险。表结构没变,不代表数据分布没变。尤其是采样数据,可能因为新增记录而变得过时。折中方案是:schema 做增量更新,采样数据按时间窗口或表大小做定期全量刷新。
缓存是另一个重要方向。线上 LLM 问答不应该每次请求都读取上下文文件。更合理的做法是:启动时加载一次上下文文件到内存,监听文件变化后热更新;或者通过 Redis 缓存上下文内容,减少 IO。缓存更新时要注意原子性,不能出现下游读取到一半旧文件、一半新文件的情况。
审计在生产环境尤其重要。每次上下文生成任务都应该记录:谁触发、从哪个数据库、使用哪个账号、筛查了哪些字段、输出到哪个位置、文件大小和耗时多少。这些日志能帮助你回答“这份上下文是从哪里来的”这个基本问题。
8.4 实际项目落地建议
Dbctx 这类工具在生产里落地,不要只把它当成一个导出脚本。它实际上是在做数据库知识的“结构化管理”:哪些信息进上下文、哪些被丢弃、多久刷新一次、谁有权看快照,这些决策比导出代码本身更重要。
如果你的项目刚开始,优先做最小闭环:一个 PostgreSQL 测试库、一张业务表、一个能生成 JSONL 或 Markdown 的脚本、一次向 LLM 注入上下文的实验。跑通之后再考虑增加外键关系、排除敏感字段、支持多 schema、接入 pgvector。
一个值得记住的判断标准是:上下文文件不是数据库的备份,而是数据库的“摘要”。它的职责是让下游快速理解数据库结构,而不是替代数据库回答所有问题。所以,能丢弃的要果断丢弃,能截断的要截断,该暴露的关系和枚举语义,一个都不能少。