know-engine

如何基于text2sql实现关系型数据查询?

在一个智能客服中,并不是所有的用户问题都是通过知识库做检索的,有一些情况,比如用户关于一些个人信息相关的东西,可能需要通过数据库查询。如以下情况,我问我的保险什么时候到期,这个问题肯定无法从知识库获得,而是应该查询数据库。 know…

TL;DR

在一个智能客服中,并不是所有的用户问题都是通过知识库做检索的,有一些情况,比如用户关于一些个人信息相关的东西,可能需要通过数据库查询。如以下情况,我问我的保险什么时候到期,这个问题肯定无法从知识库获得,而是应该查询数据库。 know…

在一个智能客服中,并不是所有的用户问题都是通过知识库做检索的,有一些情况,比如用户关于一些个人信息相关的东西,可能需要通过数据库查询。如以下情况,我问我的保险什么时候到期,这个问题肯定无法从知识库获得,而是应该查询数据库。 know-engine 采用 三路由检索架构,SQL 检索是其中的关系型数据库路由分支。整个流程基于 LangChain4j 的 RAG 管道,核心链路为:

用户提问 → 查询改写 → 意图路由 → text2sql →SQL检索 → LLM生成回答
用户提问
  │
  ▼
KnowEngineQueryTransformer  ← LLM改写 + 注入userId/时间
  │
  ▼
KnowEngineQueryRouter  ← LLM判断意图 → strategy: "relational_db"
  │
  ▼
ProgressAwareContentRetriever  ← 发送进度通知
  │
  ▼
KnowEngineSqlDatabaseContentRetriever
  │
  ├─ SqlDatabaseContentRetriever (LangChain4j)
  │     ├─ LLM 根据 Schema + Prompt 生成 SQL
  │     ├─ 通过 DataSource 执行 SQL
  │     └─ 返回格式化结果文本
  │
  ├─ 结果为空/异常? → fallbackRetriever (向量检索)
  │
  ▼
ReRankingContentAggregator  ← BGE 模型重排序
  │
  ▼
LLM 生成最终回答

核心组件及流程

查询改写 — KnowEngineQueryTransformer

在进入路由之前,用户原始查询会经过 LLM 改写优化,改写后会附加上下文信息: 改写后的查询会包含 用户ID 和 当前时间,这些信息在后续 SQL 生成时非常重要(如查询"我的保险还有多少天到期"需要 user_id 做过滤,时间比较需要当前日期)。

意图路由 — KnowEngineQueryRouter

路由器通过 LLM 判断用户意图,决定走哪个检索器。当查询涉及结构化数据查询,如车辆信息、保险信息、订单信息、数值比较、聚合操作等。那么策略为 "relational_db" 时,路由到 SQL 检索器:

case "relational_db":
    return contentRetrievers.stream().filter(retriever -> {
        if (retriever instanceof ProgressAwareContentRetriever) {
            ContentRetriever delegate = ((ProgressAwareContentRetriever) retriever).getDelegate();
            return delegate instanceof SqlDatabaseContentRetriever
                || delegate instanceof KnowEngineSqlDatabaseContentRetriever;
        }
        return retriever instanceof SqlDatabaseContentRetriever
            || retriever instanceof KnowEngineSqlDatabaseContentRetriever;
    }).collect(Collectors.toList());

SQL 检索器构建

@Value("classpath:prompts/text-to-sql-prompt.txt")
private Resource textToSqlPrompt;

@Value("classpath:sql/retrieve_tables.sql")
private Resource tablesSql;

// 构建 SQL 检索器
sqlRetriever = new ProgressAwareContentRetriever(
    KnowEngineSqlDatabaseContentRetriever.builder()
        .dataSource(dataSource)
        .promptTemplate(new PromptTemplate(textToSqlPrompt.getContentAsString(UTF_8)))
        .databaseStructure(tablesSql.getContentAsString(UTF_8))
        .chatModel(chatModel)
        .fallbackRetriever(embeddingRetriever)  // 兜底检索器
        .build(), processCallback);
  • dataSource:JDBC 数据源,执行生成的 SQL
  • promptTemplate:Text2SQL 的 Prompt 模板
  • databaseStructure:表结构定义(Schema),让 LLM 知道有哪些表和字段
  • fallbackRetriever:兜底检索器(向量检索)

Text2SQL Prompt — text-to-sql-prompt.txt

## 角色
你是一个SQL专家。请根据以下表结构信息将用户问题转换为SQL查询语句。
特别注意,你只能查询,不能做修改、删除等操作。

## 表结构信息
{{databaseStructure}}

## 要求
1. 只返回SQL语句,不需要包含任何解释和说明
2. 确保SQL语法正确
3. 使用上下文中提供的表名和字段名
4. 如果根据所提供的表无法做查询,请直接返回空字符串""

## 其他说明
今天是:{today}

数据库 Schema — retrieve_tables.sql

提供给 LLM 的表结构包含 3 张业务表(实际根据业务需要做调整): - car_info:车型信息表(品牌、型号、续航、价格等) - my_car:用户车辆表(车牌号、VIN码、保险到期日、里程等) - car_order:车辆订单表(订单状态、金额、交付日期等) 每个字段都带有详细的 comment 注释,帮助 LLM 理解字段含义。

核心检索逻辑 — KnowEngineSqlDatabaseContentRetriever

这是对 LangChain4j 原生 SqlDatabaseContentRetriever 的增强封装,核心 retrieve 方法:

@Override
public List<Content> retrieve(Query query) {
    List<Content> results;
    try {
        results = sqlDatabaseContentRetriever.retrieve(query);
    } catch (Exception e) {
        log.warn("SQL 检索异常,降级使用知识库检索, query: {}", query.text(), e);
        return fallbackRetriever.retrieve(query);  // 异常降级
    }

    if (results == null || results.isEmpty() || isSqlResultEmpty(results)) {
        log.info("SQL 检索结果为空,降级使用知识库检索, query: {}", query.text());
        return fallbackRetriever.retrieve(query);  // 空结果降级
    }
    return results;
}

降级策略: - SQL 执行异常 → 降级到向量检索 - SQL 结果为空 → 降级到向量检索 - 正常返回 SQL 查询结果

空结果判定逻辑(重要细节)

LangChain4j 的 SqlDatabaseContentRetriever 在查询无数据时不会返回空 list,而是返回包含列名但无数据行的文本。 有结果的文本:

Result of executing 'SELECT mc.plate_number, mc.insurance_expire_date \nFROM my_car mc \nWHERE mc.user_id = 'user_id' AND mc.insurance_expire_date <= '2026-05-15'':\nplate_number,insurance_expire_date\n沪A12345,2025-06-20\n沪B67890,2025-03-15

无结果的文本:

Result of executing 'SELECT mc.plate_number, mc.insurance_expire_date \nFROM my_car mc \nWHERE mc.user_id = 'user_id' AND mc.insurance_expire_date <= '2026-05-15'':\nplate_number,insurance_expire_date
Result of executing 'SELECT mc.plate_number, mc.insurance_expire_date FROM my_car mc WHERE mc.user_id = 'user_id' AND mc.insurance_expire_date <= '2026-05-15'':\nplate_number,insurance_expire_date

因此需要自定义判空逻辑:

private boolean isSqlResultEmpty(List<Content> results) {
    if (results.size() != 1) return false;
    String text = results.get(0).textSegment().text();
    if (!text.startsWith("Result of executing '")) return false;

    int columnStartIndex = text.indexOf(":\n");
    if (columnStartIndex == -1) return false;
    int dataStartIndex = text.indexOf('\n', columnStartIndex + 2);
    return dataStartIndex == -1 || text.substring(dataStartIndex + 1).trim().isEmpty();
}
版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。