01 Agent 图编排初探与知识召回节点 (Evidence Recall)
在前面的三篇文章中,我们完成了 BIRD 数据集的解析、领域模型映射,并利用 Spring AI 结合 PGVector 将所有数据进行了向量化(Embedding)。
现在,真正的“大脑”开始运转了。我们将引入 StateGraph(状态图) 的概念来编排我们的 Text2SQL Agent。今天,我们来编写这个状态图的第一关——知识召回节点(Evidence Recall Node)。
1. 为什么需要 Evidence Recall 节点?
在真实业务场景中,用户的提问往往是口语化、带有上下文的(例如:“那上个月的呢?”)。如果直接拿这种口语化的问题去向量数据库检索,效果会极差。
因此,这个节点承担着两个核心任务:
- Query Rewrite (查询重写):结合多轮对话历史,利用大模型将用户提问重写为语义完整的“独立查询(Standalone Query)”。
- Vector Retrieval (向量检索):拿着重写后的标准查询,去向量数据库中召回相关的业务名词(Glossary)和历史问答(QA Knowledge),作为后续生成 SQL 的“外部知识(Evidence)”。
2. 定义节点与提取状态输入
在我们的架构中,每一个图节点都实现了 NodeAction 接口,并通过覆盖 apply(state: OverAllState) 方法来处理业务逻辑。
首先,我们需要从流转的状态图(OverAllState)中提取用户的输入信息:
@Component
class EvidenceRecallNode(
private val chatModel: ChatModel,
private val vectorStore: VectorStore,
private val questionKnowledgeRepository: QuestionKnowledgeRepository,
private val promptManager: PromptManager
) : NodeAction {
override fun apply(state: OverAllState): Map<String, Any> {
// 从状态中获取用户当前输入、数据库隔离 ID 以及多轮对话上下文
val userInput = stringValue(state, DataAgentSpec.Graph.StateKey.Input.USER_INPUT)
val databaseId = stringValue(state, DataAgentSpec.Graph.StateKey.Input.DATABASE_ID)
val multiTurn = state.value(DataAgentSpec.Graph.StateKey.Input.MULTI_TURN_CONTEXT, "(无)")
// ... (后续逻辑)
}
}数据隔离
注意这里提取的 databaseId。由于我们的向量数据库存放了多个数据库的知识,这个字段将作为元数据过滤器(Metadata Filter),确保检索不会发生“跨库串话”。
3. 核心步骤一:利用 BeanOutputConverter 重写查询
Spring AI 提供了非常强大的 BeanOutputConverter。我们只需要定义一个 DTO,大模型就能按要求返回结构化的 JSON 数据,并在代码中被自动反序列化。
我们定义了 EvidenceQueryRewriteDTO,用于接收大模型重写后的 standalone_query 字段。
// 1. 初始化转换器
val beanOutputConverter: BeanOutputConverter<EvidenceQueryRewriteDTO> =
BeanOutputConverter(EvidenceQueryRewriteDTO::class.java)
// 2. 渲染 Prompt,传入历史对话、当前输入以及约定的 JSON 格式
val rewritePrompt = promptManager.evidenceQueryRewritePromptTemplate.render(
mapOf(
"latest_query" to userInput,
"format" to beanOutputConverter.format,
"multi_turn" to multiTurn
)
)
// 3. 调用 ChatClient (大模型)
val rewriteResponse = ChatClient.create(chatModel)
.prompt()
.options(
// 这里显式关闭了思考过程以加快重写速度
OpenAiChatOptions.builder()
.extraBody(mapOf("enable_thinking" to false))
.build()
)
.user(rewritePrompt)
.call()
.content() ?: throw IllegalArgumentException("Invalid rewrite response")
// 4. 解析结果获取重写后的独立查询
val convert = beanOutputConverter.convert(rewriteResponse)
?: throw IllegalArgumentException("Invalid rewrite response")
val rewriteQuery = convert.standaloneQuery优化点
在这个请求中,我们使用了 extraBody(mapOf("enable_thinking" to false)) 显式禁用了推理模型的长思考模式。因为重写句子属于简单任务,我们需要极低的延迟,把算力留给后面真正生成 SQL 的节点。
4. 核心步骤二:基于重写查询的精准召回
拿到了语义完整的 rewriteQuery 后,我们要去向量数据库(PGVector)里捞干货了。
我们分别执行了两次检索:召回业务名词(GLOSSARY_KNOWLEDGE)和问答库(QUESTION_KNOWLEDGE)。这里以检索 QUESTION_KNOWLEDGE 为例,使用了严谨的组合过滤器:
fun retrieveKnowledge(question: String, databaseId: String): List<Document> {
val builder = FilterExpressionBuilder()
// 精准过滤:向量类型必须是 QUESTION_KNOWLEDGE,且 databaseId 必须匹配当前库
val expression = builder.and(
builder.eq(
DataAgentSpec.Retrieval.DocumentMetadataKey.VECTOR_TYPE,
DataAgentSpec.Retrieval.VectorType.QUESTION_KNOWLEDGE
),
builder.eq(DataAgentSpec.Retrieval.DocumentMetadataKey.DATABASE_ID, databaseId)
).build()
val request = SearchRequest.builder()
.query(question)
.filterExpression(expression)
.topK(4) // 取相似度最高的 4 条
.build()
return vectorStore.similaritySearch(request)
}5. 核心步骤三:回源关系型数据库,组装状态图输出
向量数据库里存的只是文本片段,对于问答库(QA),我们需要拿到完整的“问题”和“标准 SQL 答案”。
因此,我们提取出检索命中的 KNOWLEDGE_ID,回源到关系型数据库(Jimmer 提供的 QuestionKnowledgeRepository),通过 findByIds 批量拉取完整数据,并最终组装为提示词段落:
// 提取元数据中的 ID
val ids = knowledgeDocs.mapNotNull {
it.metadata.uuidOrNull(DataAgentSpec.Retrieval.DocumentMetadataKey.KNOWLEDGE_ID)
}.distinct()
// 使用 Jimmer 批量查询
val questionKnowledgeText = questionKnowledgeRepository.findByIds(ids).joinToString("\n") {
"来源:${it.databaseId} Q: ${it.question} A: ${it.answer}"
}
// 渲染到最终的模板中
val glossaryPrompt = promptManager.businessKnowledgePromptTemplate.render(
mapOf("businessKnowledge" to glossaryKnowledgeText.ifEmpty { "无" })
)
val knowledgePrompt = promptManager.agentKnowledgePromptTemplate.render(
mapOf("agentKnowledge" to questionKnowledgeText.ifEmpty { "无" })
)
// 将召回的知识(EVIDENCE)和重写的查询(REWRITE_QUERY)写回状态图,传递给下一个节点!
return mapOf(
DataAgentSpec.Graph.StateKey.Recall.EVIDENCE to
if (questionKnowledgeText.isEmpty() && glossaryKnowledgeText.isEmpty()) "无"
else "$glossaryPrompt\n$knowledgePrompt",
DataAgentSpec.Graph.StateKey.Recall.REWRITE_QUERY to rewriteQuery
)6. 结语
在这一篇中,我们通过 EvidenceRecallNode 跑通了 Agent 状态图的第一棒。我们利用 Spring AI 的结构化输出完成了复杂的查询重写,并结合元数据过滤实现了跨多租户的安全向量召回。
有了这些精准的业务知识作为前置上下文(Evidence),大模型在生成 SQL 时将如虎添翼。在下一篇文章中,我们将继续编写Schema Recall (表结构召回) 节点,看看 Agent 是如何挑选出正确的数据库表的!
