
LangChain4j SqlDatabaseContentRetriever 实战拆解:自然语言生成 SQL 的 5 道关卡与上下文感知查询优化【免费下载链接】langchain4jLangChain4j is an idiomatic, open-source Java library for building LLM-powered applications on the JVM. It offers a unified API over popular LLM providers and vector stores, and makes implementing tool calling (including MCP support), agents and RAG easy. It integrates seamlessly with enterprise Java frameworks like Quarkus and Spring Boot.项目地址: https://gitcode.com/GitHub_Trending/la/langchain4j本文拆解 LangChain4j 的 NL2SQL 组件SqlDatabaseContentRetriever:它如何用上下文感知的方式把自然语言生成 SQL,以及如何做查询优化,把生成结果调稳。全文按一条请求在组件里的一生逐关推进,代码片段均可直接复用。先还原一次翻车现场:模型为什么写错 SQL假设你的库里有一张orders表,字段是order_id、customer_id、product_id、quantity、order_date。业务同学在对话里问:每个产品卖了多少件?模型返回了这样一条 SQL:-- 模型凭常识猜了字段:库里根本不存在 o.amount 和 o.status SELECT p.product_name, SUM(o.amount) AS total_sales FROM orders o JOIN products p ON o.product_id p.id WHERE o.status paid GROUP BY p.product_name;执行结果:column amount does not exist。问题不在模型笨,而在上下文感知不足:它不知道你的表长什么样,只能用电商订单表通常有 amount 字段这种先验去猜。这类组件的价值,就是把真实的库结构、方言、执行报错变成模型的上下文,让它看地图开车而不是闭眼摸路。SqlDatabaseContentRetriever就是干这件事的:输入自然语言Query,它生成并执行 SQL,把结果作为 RAG 的Content返回。下面带你逐关看清整条链路,并给出对应的修法。读完你可以带走:4 个 builder 配置项、1 套可直接套用的定制提示模板、1 份上线前安全清单。组件全景:一条 NL2SQL 请求的完整链路组件源码位于 experimental/langchain4j-experimental-sql/,主类是 SqlDatabaseContentRetriever.java。它实现的是 RAG 体系里的ContentRetriever接口,和向量检索器并列,只是检索目标从 Embedding Store 换成了关系型数据库:一条请求在retrieve(Query)里的完整路径如下:两个细节值得记住:非 SELECT 语句(如 DROP、UPDATE)会在isSelect处被直接拦下,返回空列表;执行失败不会抛异常给你,而是进入重试循环,耗尽后同样返回空列表。所以没查到内容和SQL 写错了在返回值上长得一样,后面会讲怎么区分。关卡一 · 让模型看对库结构:动态 DDL 与表过滤为什么。模型写 SQL 的依据只有系统提示里的那份库结构。构造器里有一个默认行为:不传databaseStructure时,组件会遍历DataSource里所有表,自动生成CREATE TABLE语句(generateDDL),列类型、主键、外键、REMARKS注释都会被读出来拼进去。问题在于:生产库往往上百张表,全量塞进 prompt 既贵又噪,模型会在无关表之间选错路。怎么做。注意generateDDL是private static,你无法直接重写它——组件官方留的定制点是 builder 的databaseStructure参数:自己准备好 DDL 字符串传进去,地图就归你管了。最小示例。手写一份收窄过的 DDL,只放业务问题真正会碰的表,并补上注释:// 只保留业务问题会用到的三张表;注释是模型理解字段语义的主要线索 String structure CREATE TABLE orders ( order_id INT PRIMARY KEY, product_id INT, quantity INT, order_date DATE ); COMMENT ON TABLE orders IS 订单明细,一行一笔订单,amount 需 quantity * products.price 计算; ; SqlDatabaseContentRetriever retriever SqlDatabaseContentRetriever.builder() .dataSource(dataSource) .databaseStructure(structure) // 覆盖默认的全表 DDL .chatModel(chatModel) .build();两个附带的做法:如果你不想手写,就把表和列的COMMENT维护进数据库——默认生成逻辑会自动读取REMARKS拼进 DDL,等于零配置拿到注释。对宽表(几十个字段)只保留关键列:字段越少,模型幻觉出一个不存在字段的机会越小。关卡二 · 重试次数怎么定:把执行错误回灌给模型为什么。即便给了完整 schema,模型仍会犯小错:拼错列名、写错函数。与其把这些错直接吞掉,不如把报错原文喂回去让它改——这是减少试错次数最直接的机制。怎么做。maxRetries控制失败后最多重试几次,默认0(即一次机会都没有)。重试的核心在retrieve的循环和generateSqlQuery的对话拼装:// 主循环:执行失败不抛出,而是把异常消息存下来再进下一轮 int attemptsLeft maxRetries 1; while (attemptsLeft 0) { attemptsLeft--; sqlQuery generateSqlQuery(naturalLanguageQuery, sqlQuery, errorMessage); sqlQuery clean(sqlQuery); // 剥掉模型爱加的 sql 围栏 if (!isSelect(sqlQuery)) { return emptyList(); // 不是 SELECT 直接放弃 } try { validate(sqlQuery); // 自定义校验钩子,见安全体检一节 // ... 执行并返回结果 } catch (Exception e) { errorMessage e.getMessage(); // 报错回灌,下一轮带上 } } return emptyList();// 上一轮的 SQL 以 AiMessage 形式、报错以 UserMessage 形式追加进对话 if (previousSqlQuery ! null previousErrorMessage ! null) { messages.add(AiMessage.from(previousSqlQuery)); messages.add(UserMessage.from(previousErrorMessage)); }取值建议。每重试一次就多一次 LLM 调用(延迟和费用翻倍),maxRetries1~2通常是稳定性 vs 成本的平衡点:// 与测试用例 SqlDatabaseContentRetrieverIT.java 中的配置一致,可对照参考 dataSource - SqlDatabaseContentRetriever.builder() .dataSource(dataSource) .sqlDialect(PostgreSQL) .databaseStructure(read(sql/create_tables.sql)) .chatModel(chatModel) .maxRetries(2) .build()一个实操建议:开启请求日志,或者接入可观测性,确认每一轮回灌的 SQL 和报错,避免重试了但一直在原地打转却不自知:另外,重试耗尽后组件返回的是空列表,和数据库里没数据无法区分。生产封装时建议在子类里把最后一轮errorMessage打到日志,排查会快很多。关卡三 · 提示词按业务定制:可套用的模板与一个坑为什么。默认模板是这样的,功能够但很通用:private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE PromptTemplate.from(You are an expert in writing SQL queries.\n You have access to a {{sqlDialect}} database with the following structure:\n {{databaseStructure}}\n If a user asks a question that can be answered by querying this database, generate an SQL SELECT query.\n Do not output anything else aside from a valid SQL statement!);组件的 javadoc 自己也承认:The default prompt template is not highly optimized, so it is advised to experiment with it。把业务规则写进系统提示,通常可显著改善特定领域的生成质量。怎么做,以及一个坑。先看createSystemPrompt()往模板里放了什么变量:protected Prompt createSystemPrompt() { MapString, Object variables new HashMap(); variables.put(sqlDialect, sqlDialect); variables.put(databaseStructure, databaseStructure); return promptTemplate.apply(variables); }坑在这里:模板里只有{{sqlDialect}}和{{databaseStructure}}两个可用变量。用户问题是作为独立的UserMessage发出去的,模板里不能写{{question}}——PromptTemplate.apply遇到没绑定的占位符会直接抛异常。很多自定义模板一上线就报错的问题都出在这。最小示例。下面这份可以按你的业务域直接改:PromptTemplate prompt PromptTemplate.from( You are a data analyst on an e-commerce database.\n Generate a {{sqlDialect}} SELECT query for the users question.\n Schema:\n{{databaseStructure}}\n Rules:\n 1. Only return the SQL statement, no explanation.\n 2. Never reference columns that do not exist in the schema.\n 3. Amount must be computed as quantity * price, there is no amount column.\n 4. Always bound time ranges with explicit date predicates.); SqlDatabaseContentRetriever.builder() .promptTemplate(prompt) // 替换默认模板 // 其余配置同上 .build();其中第 3 条规则就是针对关卡一翻车现场里模型猜出amount字段的定向修复:把这个库里没有的常识显式告诉模型,比事后重试更省。关卡四 · 方言对齐:自动检测与显式指定为什么。同一条业务问题,日期截断、分页、字符串函数的写法在三种数据库里完全不同。方言信息在系统提示里以{{sqlDialect}}注入,它准不准直接影响一次成功率。怎么做。不指定时,组件从DataSource的元数据自动检测:public static String getSqlDialect(DataSource dataSource) { // 取数据库产品名,如 PostgreSQL、MySQL、Oracle return connection.getMetaData().getDatabaseProductName(); }自动检测在测试库里一般够用,但有两个例外值得显式覆盖:连接串经过代理/中间件时,产品名可能不是你想要的字符串;你想把方言描述得更精确,比如Oracle而不是笼统的默认值。显式指定一行搞定,并配合方言注意点:// 自动检测不放心时显式指定,覆盖默认行为 SqlDatabaseContentRetriever.builder() .sqlDialect(PostgreSQL) .build();方言生成 SQL 时的注意点PostgreSQL日期区间优先DATE_TRUNC/CURRENT_DATE - INTERVAL,JSONB 用-MySQL日期函数是DATE_FORMAT/DAYNAME,分页用LIMITOracle日期用TRUNC/EXTRACT,12c 以下分页用ROWNUM上生产前的安全体检:只读账号与危险语句拦截组件源码 javadoc 开头有一段原样引用的警告:WARNING! Although fun and exciting, this class is dangerous to use! Do not ever use this in production! The database user must have very limited READ-ONLY permissions! Although the generated SQL is somewhat validated (to ensure that the SQL is a SELECT statement) using JSqlParser, this class does not guarantee that the SQL will be harmless. Use it at your own risk!中文解释:这个类标了Experimental,作者明确说别直接用在生产;即使已经用 JSqlParser 校验过必须是 SELECT,也不能保证 SQL 无害,数据库账号必须只有非常有限的只读权限。按这份警告,上线前做四件事:1. 只读账号。给DataSource配一个只有SELECT权限的库用户,这是最后一道、也是最硬的防线。2. 理解isSelect已拦什么。DROP、DELETE、UPDATE、INSERT 都不是Select,会被直接拦下返回空列表,仓库测试里也验证了这一点(见 SqlDatabaseContentRetrieverIT.java 中should_not_drop_table等用例)。3. 用validate钩子拦合法 SELECT 里的敏感数据。isSelect拦不住SELECT s.salary FROM staff s这种。默认实现是空方法,留给你的:// 只读账号挡不住查敏感表,在这里按表名/列名加白名单逻辑 Override protected void validate(String sqlQuery) { String lower sqlQuery.toLowerCase(); if (lower.contains(staff) || lower.contains(salary)) { throw new SecurityException(sensitive table is not allowed); } }4. 限制执行超时。execute是protected,子类覆写后给Statement加上超时即可,防止一条大 JOIN 把连接池占满:// 覆写执行入口:慢查询超过 10 秒直接中断,异常会自然进入重试/失败路径 Override protected String execute(String sqlQuery, Statement statement) throws SQLException { statement.setQueryTimeout(10); return super.execute(sqlQuery, statement); }执行结果本身会被格式化成带表头的 CSV(值含逗号/引号时按 RFC 4180 转义),再包成Result of executing sql: ...的Content,这个字符串会进入下游 LLM 的上下文——所以结果集行数最好在validate或提示词里约束住,避免把几十万行灌给模型。验收:一条真实业务问题过完整链路拿一个真实业务问题走一遍:上个月哪些产品类别销售额增长超过 20%?优化前,模型典型产出是这样一条能跑但答非所问的 SQL——时间写死、没有增长率计算:-- 日期是模型编的固定值;只算当月总额,回答不了增长 SELECT p.category, SUM(o.amount) FROM orders o JOIN products p ON o.product_id p.id WHERE o.order_date 2024-01-01 AND o.order_date 2024-02-01 GROUP BY p.category;经过关卡一的注释、关卡三的业务规则、关卡二的报错回灌之后,更稳的产出长这样:-- 时间锚定 CURRENT_DATE,不再依赖模型猜具体日期 SELECT p.category, SUM(o.amount) AS total_sales, LAG(SUM(o.amount)) OVER (PARTITION BY p.category ORDER BY DATE_TRUNC(month, o.order_date)) AS prev_month_sales, (SUM(o.amount) - LAG(SUM(o.amount)) OVER (PARTITION BY p.category ORDER BY DATE_TRUNC(month, o.order_date))) / LAG(SUM(o.amount)) OVER (PARTITION BY p.category ORDER BY DATE_TRUNC(month, o.order_date)) * 100 AS growth_rate FROM orders o JOIN products p ON o.product_id p.id WHERE o.order_date DATE_TRUNC(month, CURRENT_DATE - INTERVAL 1 month) AND o.order_date DATE_TRUNC(month, CURRENT_DATE) GROUP BY p.category, DATE_TRUNC(month, o.order_date) HAVING (SUM(o.amount) - LAG(SUM(o.amount)) OVER (PARTITION BY p.category ORDER BY DATE_TRUNC(month, o.order_date))) / LAG(SUM(o.amount)) OVER (PARTITION BY p.category ORDER BY DATE_TRUNC(month, o.order_date)) * 100 20;后者更稳的三点原因:时间范围用CURRENT_DATE相对推导,换一个月再问不用改;LAG窗口函数把环比增长从业务语义显式翻译成 SQL,而不是指望模型用两条查询拼;HAVING在数据库侧过滤,结果集直接就是答案。想本地复现整条链路,可以照着 experimental/langchain4j-experimental-sql/ 的集成测试来:它用 Testcontainers 拉起 PostgreSQL,建好customers / products / orders三张表(建表脚本见 create_tables.sql),再逐条断言生成结果。收尾:组件边界、后续方向与延伸阅读先说边界:类上标着Experimental,javadoc 明确不建议直接上生产,前面一节的安全清单是配套前提。源码里还留了两条TODO (for v2),等于官方路线图:给每张表在 prompt 里附带几行样例数据(行级示例,让模型看到真实取值风格)、以及表级 allow/deny 列表。再往外的可能方向是查询性能建议(比如提示模型走索引列)。这几项落地之前,关卡一手写 DDL的做法就是现行最佳替代。延伸阅读:RAG 教程中关于 SQL Database Content Retriever 的说明:docs/docs/tutorials/rag.md组件源码(含完整 javadoc 与参数说明):SqlDatabaseContentRetriever.java行为基准测试:测试类 SqlDatabaseContentRetrieverIT.java、单测 SqlDatabaseContentRetrieverTest.java下一步建议:找一个非核心的库,按关卡一收窄 schema → 关卡三定制模板 → 关卡二开一次重试的顺序跑通,再对照本文的安全清单决定是否扩大到生产。【免费下载链接】langchain4jLangChain4j is an idiomatic, open-source Java library for building LLM-powered applications on the JVM. It offers a unified API over popular LLM providers and vector stores, and makes implementing tool calling (including MCP support), agents and RAG easy. It integrates seamlessly with enterprise Java frameworks like Quarkus and Spring Boot.项目地址: https://gitcode.com/GitHub_Trending/la/langchain4j创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考