02 表结构召回节点 (Schema Recall)
在上一篇文章中,我们通过 EvidenceRecallNode 提取了用户的提问意图,将其重写为了语义完整的独立查询(Standalone Query),并捞取了相关的业务名词和历史 QA 作为外部知识。
有了业务上下文,下一步大模型就需要写 SQL 了。但是,大模型并不是神,它不可能凭空知道你的数据库里有哪些表和字段。如果你把整个数据库几百张表、几千个字段的 DDL 全塞给大模型,不仅会撑爆 Context Window(上下文窗口),还会导致模型严重幻觉。
因此,今天我们要编写 StateGraph 的第二个节点——表结构召回节点(Scheme Recall Node),利用向量检索精准地找出与用户问题相关的 Table 和 Column。
1. 核心代码实现
在这个节点中,我们要拿着上一节点重写后的 REWRITE_QUERY,去我们存满表结构 Embedding 数据的 PGVector 数据库中进行相似度检索。
以下是 SchemeReCallNode 的完整实现:
@Component
class SchemeReCallNode(private val vectorStore: VectorStore) : NodeAction {
override fun apply(state: OverAllState): Map<String, Any> {
// 1. 获取前置节点的输出:重写后的查询句子,以及隔离租户的 databaseId
val rewriteQuery = state.value(DataAgentSpec.Graph.StateKey.Recall.REWRITE_QUERY, "")
val databaseId = state.value(DataAgentSpec.Graph.StateKey.Input.DATABASE_ID, "")
// 2. 分别执行表级别和列级别的向量召回
val tableDocuments = retrieveTable(rewriteQuery, databaseId)
val columnDocuments = retrieveColumn(rewriteQuery, databaseId)
// 3. 将召回的 Schema 写入状态图,向下游传递
return mapOf(
DataAgentSpec.Graph.StateKey.Recall.TABLE_SCHEMA to tableDocuments,
DataAgentSpec.Graph.StateKey.Recall.COLUMN_SCHEMA to columnDocuments
)
}
fun retrieveTable(question: String, databaseId: String): List<Document> {
val builder = FilterExpressionBuilder()
val expression = builder.and(
builder.eq(
DataAgentSpec.Retrieval.DocumentMetadataKey.VECTOR_TYPE,
DataAgentSpec.Retrieval.VectorType.TABLE
),
builder.eq(DataAgentSpec.Retrieval.DocumentMetadataKey.DATABASE_ID, databaseId)
).build()
val request = SearchRequest.builder()
.query(question)
.filterExpression(expression)
.topK(10) // 取最相关的 10 张表
.build()
return vectorStore.similaritySearch(request)
}
fun retrieveColumn(question: String, databaseId: String): List<Document> {
val builder = FilterExpressionBuilder()
val expression = builder.and(
builder.eq(
DataAgentSpec.Retrieval.DocumentMetadataKey.VECTOR_TYPE,
DataAgentSpec.Retrieval.VectorType.COLUMN
),
builder.eq(DataAgentSpec.Retrieval.DocumentMetadataKey.DATABASE_ID, databaseId)
).build()
val request = SearchRequest.builder()
.query(question)
.filterExpression(expression)
.topK(30) // 取最相关的 30 个字段
.build()
return vectorStore.similaritySearch(request)
}
}2. 核心逻辑拆解与设计思考
2.1 状态继承与连贯性
在 apply 方法的开头,我们通过 DataAgentSpec.Graph.StateKey.Recall.REWRITE_QUERY 读取了上一个节点写进去的变量。这就是图编排(StateGraph)的魅力所在:上游节点的输出,自动成为下游节点的输入,数据流转非常清晰。
2.2 精准 Metadata 过滤
我们的向量数据库是多租户混合存储的(既有学校数据,又有游戏数据等)。所以在查询时,绝对不能只传入 question,必须通过 Spring AI 的 FilterExpressionBuilder 加上严密的元数据约束:
VECTOR_TYPE必须等于table或column,防止把知识库的文本混进来。DATABASE_ID必须匹配用户当前所在的数据库环境。
2.3 topK 的策略博弈:10 张表与 30 个字段
细心的读者会发现,retrieveTable 设置了 topK(10),而 retrieveColumn 设置了 topK(30)。
为什么是 10 和 30?
- Table (10张表):通常一个复杂的联合查询最多涉及 4-6 张表。召回 10 张表足以覆盖绝大多数场景,同时能把提示词长度控制在合理的范围内。
- Column (30个字段):数据库字段的描述(Description)通常很短,但很多业务逻辑往往隐藏在字段名中。召回 30 个字段是为了确保即使问题很隐蔽(比如问“绩点”,对应的可能是
gpa_score),也能大概率被捞出来。
:::
3. 现在的痛点与下一步预告
走到这里,我们的状态图里已经有了 TABLE_SCHEMA (10 张表) 和 COLUMN_SCHEMA (30 个字段)。
但这足够让大模型写出正确的 SQL 吗?
🚨 危机出现:外键丢失与关联断裂
仔细思考一下,我们召回的这 10 张表和 30 个列,是基于语义相似度散乱地捞出来的!
假设大模型需要生成 JOIN 语句,它怎么知道表 A 和 表 B 之间应该用哪个字段去 ON 呢?如果不补全它们之间的外键和主键关联,模型很可能会靠猜,从而生成出语法完全错误、或者产生笛卡尔积的烂 SQL。
这就是 Text2SQL 中极易被忽视、却又最为致命的难点——Schema Linking(结构链接)。
在下一篇文章中,我们将编写第三个节点:表关联补全节点 (Table Relation Node)。我们将展示如何利用代码在内存中重建这些散装表和字段的 DDL,并精准补齐它们之间的外键(Foreign Key)依赖!敬请期待。
