第 03 章 --- Schema 检索(应对大库)
层级:L1 基础层 + L2 能力层 目标:当表数量爆炸时,只把"相关"的表注入 prompt,解决 token 超限与选表干扰。 本章产出:一个基于向量检索的 SchemaRetriever。 依赖:第 02 章的
schema.py。
上一章的痛点
第 02 章能自动提取全库 Schema 了。但开放题已经点破:全库注入在大库上直接崩。
- 1200 张表 → 40 万 token → 超上限。
- 就算塞得下,噪声表会干扰模型选表,准确率反而下降。
- 每次请求都带全量 Schema,成本高、延迟大。
核心矛盾:用户一次提问通常只涉及 2~5 张表,却付了全库的代价。
解法:先召回相关的表,再注入。这是从"全量"到"检索"的关键跃迁。
核心原理:语义检索
思路和 RAG 一样:
离线:把每张表的描述 → 向量 → 存进向量库
在线:用户问题 → 向量 → 检索最相似的 K 张表 → 只注入这 K 张
问题"上月各城市中止率"和表 drives(含 status/drive_date)、vehicles(含 city) 语义相近,会被召回;和 scenarios 关系弱,被过滤。
层级设计
检索器仍在 L1/L2 边界,它消费第 02 章的 Table,产出"被选中的子集":
markdown
L2 SQL 生成 ← 只拿到相关的 K 张表
▲
L1 SchemaRetriever(本章)← 向量召回
▲
SchemaProvider(第 02 章)
构建可嵌入的表文档
先把每张表压成一段用于检索的"文档"。检索文档和注入文本可以不同 ------ 检索用的可以更侧重语义关键词。
retriever.py:
python
"""L1: Schema 检索 ------ 大库场景只召回相关表。"""
from dataclasses import dataclass
import anthropic
from schema import Table, extract_schema, render_schema
client = anthropic.Anthropic()
def table_to_doc(t: Table) -> str:
"""把表压成一段适合语义检索的文档。"""
col_names = ", ".join(c.name for c in t.columns)
# 把样例值也拼进去,让"北京""中止"这类词能命中
samples = []
for c in t.columns:
if c.sample_values:
samples.extend(str(v) for v in c.sample_values)
sample_text = ", ".join(samples[:20])
return f"表 {t.name},字段:{col_names}。数据样例:{sample_text}"
向量化与检索
生产会用专门的 embedding 服务(如 Voyage、OpenAI embeddings)。这里为了聚焦原理,用一个可替换的 embedder 接口,并给一个基于 embedding API 的实现。
在 retriever.py 追加:
python
import math
def embed(texts: list[str]) -> list[list[float]]:
"""把文本批量转成向量。生产可换任意 embedding 服务。
这里用 Voyage/OpenAI 兼容接口示意;教学也可换成本地模型。
"""
# 伪代码占位:实际调用你的 embedding 服务
# resp = embed_client.embeddings.create(model=..., input=texts)
# return [d.embedding for d in resp.data]
raise NotImplementedError("接入你的 embedding 服务")
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb + 1e-9)
@dataclass(frozen=True)
class IndexedTable:
table: Table
vector: tuple[float, ...]
class SchemaRetriever:
"""离线建索引,在线按问题召回 top-k 表。"""
def __init__(self, tables: tuple[Table, ...]):
docs = [table_to_doc(t) for t in tables]
vectors = embed(docs)
self._index = tuple(
IndexedTable(table=t, vector=tuple(v))
for t, v in zip(tables, vectors)
)
def retrieve(self, question: str, k: int = 5) -> tuple[Table, ...]:
qv = embed([question])[0]
scored = sorted(
self._index,
key=lambda it: cosine(qv, list(it.vector)),
reverse=True,
)
return tuple(it.table for it in scored[:k])
教学替代:没有 embedding 服务也能跑
如果你暂时没有 embedding API,用一个关键词重叠的 fallback 也能演示效果(生产不要用,仅用于本地体验流程):
python
class KeywordRetriever:
"""零依赖的关键词召回 ------ 仅用于本地跑通流程,非生产方案。"""
def __init__(self, tables: tuple[Table, ...]):
self._tables = tables
def retrieve(self, question: str, k: int = 5) -> tuple[Table, ...]:
def score(t: Table) -> int:
doc = table_to_doc(t)
# 命中的字符越多分越高(极简,仅演示)
return sum(1 for ch in set(question) if ch in doc)
ranked = sorted(self._tables, key=score, reverse=True)
return tuple(ranked[:k])
真实项目务必用向量检索 ------ 关键词召回处理不了"接管/脱离/里程"这类同义词。这正是语义检索的价值。
接入问数器
agent_v3.py:
python
"""v3: 带 Schema 检索的问数器。"""
import sqlite3
import anthropic
from schema import extract_schema, render_schema
from retriever import SchemaRetriever # 或 KeywordRetriever
client = anthropic.Anthropic()
PROMPT = """你是 SQL 专家。下面是与问题最相关的表结构(已从大库中检索)。
若你发现缺少必要的表,请只回答:NEED_MORE_TABLES
数据库结构:
{schema}
规则:只输出 SQL 或 NEED_MORE_TABLES,不要解释。只允许 SELECT。
用户问题:{question}
"""
class QueryAgent:
def __init__(self, db: str = "drive.db"):
self.db = db
tables = extract_schema(db)
self.retriever = SchemaRetriever(tables)
def ask(self, question: str, k: int = 5) -> list:
relevant = self.retriever.retrieve(question, k=k)
print(f"召回的表: {[t.name for t in relevant]}")
schema_text = render_schema(relevant)
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=500,
messages=[{
"role": "user",
"content": PROMPT.format(schema=schema_text, question=question),
}],
)
sql = resp.content[0].text.strip()
if sql == "NEED_MORE_TABLES":
# 兜底:召回不足时放大 k 或降级为全量
print("召回不足,放大检索范围重试")
return self.ask(question, k=k * 2)
print(f"SQL: {sql}")
conn = sqlite3.connect(self.db)
try:
return conn.execute(sql).fetchall()
finally:
conn.close()
if __name__ == "__main__":
agent = QueryAgent()
print(agent.ask("北京有多少次中止的路测?"))
注意两个生产级设计细节:
- 召回不足的兜底 :让模型能说"表不够"(
NEED_MORE_TABLES),触发放大检索。这是从"盲目生成"到"知之为知之"的关键 ------ 第 06 章的自愈会大量用到这种"承认失败"的信号。 - k 是可调超参:k 太小漏表,太大回到噪声问题。生产要按库规模和评估集调。
召回质量的进阶技巧(生产必备)
单纯向量召回在生产往往不够,常见增强:
- 列级召回:不只召回表,还召回具体列(宽表 200 列时,只给相关列)。
- 关系补全 :召回了
interventions但漏了drives,用外键图自动把中间表补上,否则 JOIN 断链。 - 混合检索:向量召回 + 关键词/BM25,再融合排序(术语精确匹配 + 语义泛化)。
- 业务词典:"加权接管量→SUM(takeover_count*risk_weight)"这类映射,用同义词表把问题改写后再检索。
这些不必现在全实现,但要知道它们存在 ------ 它们是问数准确率从 60% 到 90% 的关键。
本章小结
- 大库场景必须从"全量注入"转向"检索后注入"。
- 检索文档里带上样例值,能让业务词命中相关表。
- 让模型能表达"召回不足",是鲁棒性的重要信号。
- 生产要做列级召回、关系补全、混合检索、业务词典。
【开放性问题 · 生产案例】
一家自动驾驶路测数据平台的问数系统在演示时准确率 90%,上线后掉到 55%。排查发现:
- 用户爱用行话:"把 MPI 拉一下""看看接管率" ------ 向量检索完全召回不到相关表,因为表名字段名都是英文技术命名(
miles_per_intervention、disengagement_rate)。 - 有张"路测数据万能宽表"250 个字段,几乎每个问题都召回它,但模型在 250 列里选错列。
- 安全团队和运营团队对"接管"的定义不同(人工主动接管 vs 系统降级接管),同一个词该召回不同的表。
请思考:
- 问题 1 该在检索前 还是检索后解决?"业务词典/问题改写"应该放在流程的哪一步?
- 问题 2 说明"表级召回"粒度不够,你会怎么设计"列级召回"?宽表的列要不要也向量化?
- 问题 3 触及了一个更深的问题:同一个词在不同角色/场景下语义不同。这还只是检索能解决的吗?(提示:这需要"上下文"------第 07 章多轮对话会带来用户角色信息)
下一章:04 --- Few-shot 与生成质量,解决"SQL 语义写不对"的准确率问题。
ruby
项目地址:https://github.com/qiuqiuqiu123/text2sql-agent.git
如果你也在做 AI Agent 编排相关的事情,欢迎交流。
微信公众号「夜猫子agent工坊」
感谢小木API的赞助:https://xiaomuai.cn