RAG 问答系统落地:从默认分割器的坑,到 Milvus 召回调优
这是我"Java 转 AI 工程"系列的第 9 篇。前面把 Graph 工作流、工程底座、提示词都摸过一遍了,这一章要真刀真枪搭一个东西:智能 BI 报表问答系统------业务方自然语言提问,系统检索企业表结构知识库、生成 SQL、执行后把结果表发回邮箱。核心是 RAG,而最容易翻车的地方我放在第六节。
一、业务方的一句话,先把大模型问住了
业务方在群里问:"上个月华东区哪个 SKU 退货率最高?"
这句话扔给任何一个通用大模型,它都会给你一个看起来很专业的答案------语法正确、表名全是你没听过的 SQL。它没见过你的库,只能猜;猜错了,你拿着错误报表去开周会。
我后来拿一个更朴素的问题试我们这套 BI 库:"计算 2025 年每个月的销售总额,并按月份升序排列"。不难,难的是模型得知道:销售数据在 fact_sales;"月份"不在事实表里,得 JOIN dim_date;金额字段有三个------sales_amount(实收)、original_amount(应收未折扣)、discount_amount(折扣),问"销售总额"该用哪个。而开头那个问题永远答不出来:我们这套表里压根没有退货字段。RAG 只能补"模型不知道的知识",补不了"数据本身不存在"。
二、RAG 解决什么,以及它不解决什么
RAG(检索增强生成)说白了就一句:先把相关资料查出来塞进提示词,再让模型回答。 对企业场景它解决两件事------数据安全 :表结构、字段含义、业务口径不进第三方模型语料,只在一次请求里作为上下文出现;知识定制:把企业文档做成专属知识库,模型答的是"你的"业务。
它不解决什么,我也是踩过才知道:不解决数据缺失 ,库里没有的字段检索出来照样是空的;不解决执行安全 ,RAG 生成的是一条可执行 SQL,它不会替你判断这条语句会不会 DROP TABLE(第 10 章的主题);不自动解决召回质量,"上了 RAG"和"RAG 能用"之间隔着分割器、向量模型、TopK、提示词一整套调优。
整个系统拆成两条链路,后面所有代码都挂在这两条线上:
text
写入链路:文档上传 → Tika 读取 → 文本分割 → 向量化 → 存入向量库
检索链路:用户问题 →(查询改写)→ 问题向量化 → 相似度召回 → 上下文注入 → 生成
三、工程骨架:ai-bi-helper
父工程 double-ai-agent 下新建子模块 ai-bi-helper,启动类改名 BiApp 挂 @SpringBootApplication,包 com.carl.ai.bi.helper 下分 config、controller、nodes、service/impl、splitter。依赖起步四个:spring-ai-alibaba-graph-core、spring-ai-autoconfigure-model-openai、spring-ai-autoconfigure-model-chat-client、spring-ai-tika-document-reader。版本沿用第 3 篇那套(Spring AI 1.0.3 + Alibaba 1.0.0.4 + JDK 17 + Boot 3.x)。配置里有两处我一开始没搞明白:
yaml
server:
port: 8877 # 8876 已经被 ai-transfer 占了
spring:
ai:
openai:
api-key: ${DASHSCOPE_KEY} # 外层这个 key 不能删
chat:
base-url: https://dashscope.aliyuncs.com/compatible-mode
api-key: ${DASHSCOPE_KEY}
options:
model: qwen3-max
embedding:
base-url: https://api.siliconflow.cn
api-key: ${SILICONFLOW_KEY}
options:
model: BAAI/bge-large-zh-v1.5
- 对话模型和向量模型来自两家 :对话用阿里云百炼
qwen3-max,向量化用硅基流动的bge-large-zh-v1.5。Spring AI 的 OpenAI 配置支持chat和embedding各自下沉一层base-url+api-key,这就是多厂商混配的入口。 - 但外层
spring.ai.openai.api-key必须留着 。我按直觉把外层删掉只留内层,启动直接抛OpenAI API key must be set------自动装配阶段先校验外层属性,之后才轮到分模型配置生效。
四、文档进向量库:Tika 只是第一步
Spring AI 把"文档变向量"抽象成 ETL 管道:DocumentReader(读)→ DocumentTransformer(切)→ DocumentWriter(写)。读取器有四个现成实现:JsonReader、TextReader、JsoupDocumentReader(HTML,支持 CSS 选择器),以及一把梭的 TikaDocumentReader------靠 Apache Tika 兜住 PDF/DOCX/PPTX/HTML 等几乎所有办公格式。
我们的知识库文档是 Word,直接上 Tika。Controller 就是一个 @GetMapping("/upload") 收 MultipartFile 转给 Service(顺手写的 GET,文件上传正经应该 POST):
java
@Service
public class DocumentServiceImpl implements DocumentService {
@Resource private VectorStore vectorStore;
/** 1.读取文档 2.拆分 3.向量化 4.入库 */
@Override
public void handleDocument(MultipartFile file) {
TikaDocumentReader reader = new TikaDocumentReader(file.getResource());
List<Document> documents = reader.get();
vectorStore.add(documents);
}
}
读出来的每个 Document 带四个关键字段:id、text(入库落在向量的 content 字段)、metadata(Tika 自动塞 source=BI_Table_Schema.docx)、score(检索回来才有值)。
五、向量化模型:把"文本→坐标"交给智源 bge
向量模型和对话模型是两类东西:对话模型输出 token,向量模型输出一个浮点数数组 ,数组长度就是向量维度 ;语义相近的两段话在这个空间里距离也近,相似度检索算的就是这个距离。EmbeddingModel 接口刻意做得很薄,embed(String) 和 embed(Document) 覆盖 90% 用法,剩下都是"换模型"------在硅基流动这类聚合平台上就是改一行配置。
我挑模型时列了个表:
| 模型 | 维度 | 上下文 | 说明 |
|---|---|---|---|
BAAI/bge-large-zh-v1.5 |
1024 | 512 | 智源出品,中文专用,免费,C-MTEB 表现好 |
BAAI/bge-m3 |
1024 | 8K | 多语言,支持密集/稀疏/多向量检索 |
两个坑是联排的:维度决定向量库建表参数 (embedding-dimension 必须和模型输出严格一致,否则写不进去);上下文长度决定一次能塞多长文本 。我一开始选了 bge-large-zh-v1.5,中文好又免费,看起来没毛病------直到上传了第一篇真实文档。
六、默认分割方案的坑:一篇文档 = 一个向量
这一节是整章我最想写的,它教会我一件事:RAG 的效果问题,八成不在模型,在切分。
我拿来做测试的第一份文档是《刑法》全文,Word 状态栏 12463 字 。上传,接口 500,报 Tokens exceeds maximum。原因很直白:TikaDocumentReader.get() 对一份 docx 只返回一个 Document ,vectorStore.add() 就把整篇原文丢给 embedding 模型,而它的上下文只有 512。Spring AI 的 ETL 管道默认不会替你切 ,切分是一个显式的 DocumentTransformer 步骤。
绕过去了,但只是把问题藏起来。 我当时的第一反应是拍脑袋三板斧:换更大上下文的模型、把长文档拆成多个短文档、手工删内容砍到 4000 字左右。第三条居然真跑通了------接口 200。然后我打开 Redis 一看,愣住了:整个 DB0 里跟知识库相关的键只有一个 ,bi-helper-prefix!194718ef-62cb-...,它的 content 里躺着那 4000 字全文,前面是一个巨大的向量数组。
这就是"能跑"和"能用"的分水岭:
| 做法 | 向量库里存了什么 | 召回时会发生什么 |
|---|---|---|
| ✗ 不切分,靠大上下文硬扛 | 1 篇 = 1 条向量 | 命中就是命中整篇,4000 字原文全量进提示词,模型自己在里面找答案 |
| ✗ 手工删减文档 | 1 篇(变短的)= 1 条向量 | 同上,而且信息被人为删掉,口径丢失不可逆 |
| ✓ 按语义单元切分 | N 个章节 = N 条向量 | 命中的是"2.1 商品维度"这种自解释的一块,上下文小且精准 |
用 Java 工程师的话类比:这就像为了查一个字段,把整张表塞进一个 TEXT 大字段。长文本字段的检索效率天然低于结构化短字段------不是模型问题,是数据建模问题。真正致命的是:切分粒度一旦错了,后面调 topK、调阈值、调提示词,全是在错误的地基上刷漆。
七、自定义分割器:按数字标题切,也按中文句号切
扩展点很干净:继承 org.springframework.ai.transformer.splitter.TextSplitter,实现 protected List<String> splitText(String text)。父类负责把 List<String> 装回 List<Document>、复制 metadata、算 embedding,你只管"怎么切"。
分割器一:HeadingTextSpliter(结构化文档)
表结构文档是标准 Word 编号标题(1.、2.1、3.2),那就按标题切,标题本身要留在块里------否则召回回来一段没有主语的字段列表,模型不知道它属于哪张表:
java
public class HeadingTextSpliter extends TextSplitter {
// 匹配 Word 文档的所有数字编号标题:1、1.1、2.3.4 等
private static final Pattern HEADING_PATTERN =
Pattern.compile("^\\d+(?:\\.\\d+)*\\s+.*$", Pattern.MULTILINE);
@Override
protected List<String> splitText(String text) {
List<String> blocks = new ArrayList<>();
if (text == null || text.trim().isEmpty()) return blocks;
// 1) 记录每个标题的起始索引
Matcher matcher = HEADING_PATTERN.matcher(text);
List<Integer> starts = new ArrayList<>();
while (matcher.find()) starts.add(matcher.start());
// 2) 一个标题都没有:整篇作为一块返回(宁可粗,不丢内容)
if (starts.isEmpty()) { blocks.add(text.trim()); return blocks; }
// 3) 第一条标题不在开头:前言单独成块
int firstStart = starts.get(0);
if (firstStart > 0) {
String preamble = text.substring(0, firstStart).trim();
if (!preamble.isEmpty()) blocks.add(preamble);
}
// 4) 按标题起点切,每块 = 标题 + 它下面的全部内容
for (int i = 0; i < starts.size(); i++) {
int from = starts.get(i);
int to = (i + 1 < starts.size()) ? starts.get(i + 1) : text.length();
String block = text.substring(from, to).trim();
if (!block.isEmpty()) blocks.add(block);
}
return blocks;
}
}
接入只要把 reader.get() 换成 new HeadingTextSpliter().split(documents)。
分割器二:ChineseTextSpliter(连续性文本)
法律条文、手册这种没编号的呢?按中文标点分句 + 滑窗,核心就是这段(chunkSize 是单块最大长度,chunkOverlap 是块间重叠字符数):
java
private static final Pattern SENTENCE_SPLIT_PATTERN = Pattern.compile("(?<=[。!?:])");
StringBuilder cur = new StringBuilder();
for (String s : SENTENCE_SPLIT_PATTERN.split(text.trim())) {
if (cur.length() + s.length() > chunkSize) {
segments.add(cur.toString().trim());
// 留上一块尾部若干字符做重叠,避免语义腰斩
cur = new StringBuilder(cur.substring(cur.length() - chunkOverlap));
}
cur.append(s);
}
验证:从 1 条变成 6 条
换用 BI_Table_Schema.docx 重新入库,一行日志就说明结果了:TextSplitter - Splitting up document into 6 chunks.
去向量管理界面看,biHelper 这个 collection 里整整齐齐 6 行,每行 content 开头都是一个自解释的标题,metadata 都带着 {"source":"BI_Table_Schema.docx"},embedding 是 1024 长度的数组:
text
企业智能 BI 数据库表结构说明文档 1. 文档... ← 前言块
2.1 商品维度(dim_product) 字段名 | 类型...
2.2 门店维度(dim_store) 字段名 | 类型...
2.3 时间维度(dim_date) 字段名 | 类型...
3.1 销售事实表(fact_sales) 字段名 | 类型...
3.2 库存事实表(fact_inventory)字段名 | ...
这才是知识库。另外两个小坑:Tika 解析会尝试 OCR,大图多的 PDF 会显著拖慢入库 ;文档改了向量库必须重建 ------我中途加了"顾客维度表"、给 fact_sales 补了 customer_id,忘了重灌知识库,模型生成的 SQL 里就死活没有 customer_id,排查半天是知识库和文档不同步。
八、向量库:Redis 只是过渡,Milvus 才是归宿
Redis 做向量库最大的好处是学习成本几乎为零 ,但它本体不支持向量检索,必须用 Redis Stack:docker run --name redis-stack -p 6379:6379 redis/redis-stack-server:latest。依赖 spring-ai-starter-vector-store-redis,配置三个键:index-name: bi-helper、prefix: bi-helper-prefix(落库 key 就是 bi-helper-prefix!<uuid>)、initialize-schema: true(开发期很爽,生产上它会替你改索引结构),外加 spring.data.redis 的 host / port: 6379 / timeout: 5s。
为什么非换不可
| 维度 | Redis Stack | Milvus |
|---|---|---|
| 定位 | KV 库顺带做向量 | 专为向量而生 |
| 数据规模 | 全内存,量一大就吃紧 | 冷热分离,热数据内存/SSD |
| 检索能力 | ANN 为主 | ANN、过滤、多向量混合、BM25 全文 |
| 数据组织 | 靠 key 前缀硬凑 | Database / Collection / Partition |
| 运维 | 泛型客户端凑合看 | Attu 图形化界面 |
Milvus 是国产开源,官方口径核心组件 C++ 实现、支持 GPU 加速,性能是同类库数倍(我没实测)。真正说服我换的是 Attu:能直接看 collection 的 schema 和数据行,调召回时省掉一半瞎猜。
部署与集成
一条 compose 起四个服务:etcd(quay.io/coreos/etcd:v3.5.18)、minio、milvus-standalone 本体、attu 界面。端口记四个:19530 服务端口(容器间用 milvus-standalone:19530)、9091 健康检查、9000 MinIO、3000 Attu(打开后填 127.0.0.1:19530 + 数据库 default)。
依赖把 Redis 那段整段注释掉换成 spring-ai-starter-vector-store-milvus。配置:
yaml
spring:
ai:
openai:
embedding:
options:
model: BAAI/bge-m3 # 换成 8K 上下文的模型
vectorstore:
# redis: # 必须注释,两个 vectorstore 会抢 Bean
milvus:
client:
host: localhost
port: 19530
collection-name: biHelper
database-name: default
id-field-name: id
auto-id: false # 用 Document 自带的字符串 UUID
initialize-schema: true
# 这个向量维度需要根据你使用的向量大模型来设置
embedding-dimension: 1024
迁移踩的坑,按疼的程度排序:坑 1,Invalid collection name :我顺手沿用 Redis 那边的命名写了 bi-helper,而 Milvus 集合名只接受字母、数字、下划线 ,中划线直接拒,改成 biHelper 就过了。
坑 2,启动即 NPE,堆栈还指不到真因:
java
Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Boolean.booleanValue()"
because the return value of "io.milvus.param.R.getData()" is null
at MilvusVectorStore.isDatabaseCollectionExists(440) → createCollection(446)
→ afterPropertiesSet(424)
字面意思完全看不出:Milvus 容器还没起来 ,客户端拿回一个 data 为 null 的响应,直接拆箱就炸。这类"根因和异常现场隔了三层"的报错,我的办法是先别看堆栈,先看 docker ps。同理,DEADLINE_EXCEEDED 和 Milvus Proxy is not ready yet 也不是配置错,而是四个容器里 Proxy 最后就绪------等半分钟,别急着重启应用。
九、BI 问答的 Graph 工作流与前置数据
我沿用第 3 篇"先画图再写码"的习惯,初级版本刻意做短------线性、无分支、无循环,目标是把链路打通 ,评估节点、重试、人机审批都是后面的事:START → GenSQLNode(RAG 检索 + 生成 SQL)→ 执行 SQL(顺带导出 Excel)→ 发送邮件 → END。
系统总得先有可问的数。我建了 bi_helper 库(utf8mb4),3 维度 + 2 事实的星型模型:
| 表 | 角色 | 主要内容 | 数据量 |
|---|---|---|---|
dim_product |
维度 | product_id、product_name、category_id/name、brand、成本价与零售价 |
20 |
dim_store |
维度 | store_id、store_name、province、city、address、open_date |
10 |
dim_date |
维度 | date_id、date、year、quarter、month、day、week、weekday |
31(2025-01) |
fact_sales |
事实 | sales_id、三个外键、quantity、三个金额字段 |
300 |
fact_inventory |
事实 | inventory_id、三个外键、stock_qty、stock_value、最近出入库时间 |
300 |
sql
CREATE TABLE fact_sales (
sales_id BIGINT NOT NULL COMMENT '销售记录唯一ID',
date_id INT COMMENT 'FK -> dim_date(date_id) 销售日期',
store_id BIGINT COMMENT 'FK -> dim_store(store_id) 销售门店',
product_id BIGINT COMMENT 'FK -> dim_product(product_id) 销售商品',
sales_amount DECIMAL(10,2) COMMENT '销售金额(实收)',
/* ... quantity、original_amount、discount_amount、customer_id 等 */
);
"金额"一个表里放三个字段,正是它教会我:字段注释写得越像人话,AI 生成的 SQL 越准。 模型分不清"销售总额"该用实收还是应收,全靠 COMMENT 里那两个字。图配置:
java
@Configuration
public class GraphConfig {
@Resource private VectorStore vectorStore;
@Bean
public CompiledGraph graph(ChatClient.Builder chatClientBuilder) throws Exception {
KeyStrategyFactory keyStrategyFactory = () -> Map.of(
"userInput", new ReplaceStrategy(),
"genSQL", new ReplaceStrategy());
StateGraph stateGraph = new StateGraph("biHelperGraph", keyStrategyFactory);
stateGraph.addNode("GenSQLNode",
node_async(new GenSQLNode(chatClientBuilder, vectorStore)));
// 单节点调试期:让 START 直连目标节点
stateGraph.addEdge(START, "GenSQLNode");
stateGraph.addEdge("GenSQLNode", END);
return stateGraph.compile();
}
}
只测一个节点时就把图退化成 START → 目标节点 → END ,省得为了验证提示词先把后面两个节点用假数据糊出来。另外第 3 篇那个"key 名对不齐导致链路静默断裂"的坑我又踩了一次:节点里读 userInput,Controller 传 input,图跑完了 SQL 是空的,一点异常都没有。
十、召回 + 生成:GenSQLNode 与提示词原文
这是整条链路的心脏:
java
public class GenSQLNode implements NodeAction {
private final ChatClient.Builder chatClientBuild;
private final VectorStore vectorStore;
/** 1.实现 RAG 的召回过程 2.定义提示词 3.与 LLM 交互(对话) */
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String userInput = state.value("userInput", "");
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore).build())
.build();
Flux<String> content = chatClientBuild.build().prompt()
.advisors(advisor)
.system("""
# 角色
你是一名熟练的 SQL 专家,负责根据企业数据表结构生成 SQL 查询。用户将以自然语言提出数据需求。
你有能力访问企业数据库表结构和表之间的关系(这些信息通过矢量数据库检索得到)
# 要求
1. 仅生成可执行的 SQL,不输出任何与 SQL 无关的文字或解释。
2. 在生成 SQL 前,首先理解用户需求和检索得到的表结构信息。
3. 根据表结构和关系选择合适的表和字段,生成可执行的 SQL。
4. 输出 SQL 时,禁止使用 markdown 格式 ```sql 来输出,直接以文本格式输出。
5. 如果存在多种实现方式,优先选择最简洁、性能较优的写法。
6. 禁止输出与 SQL 无关的文本或解释。
7. 不可凭空虚构数据,若数据不足,请返回空字符串。
""")
.user(userInput)
.stream().content();
StringBuilder sb = new StringBuilder();
content.doOnNext(sb::append).blockLast();
log.info("生成的 SQL => {}", sb);
return Map.of("genSQL", sb.toString());
}
}
几处设计意图值得单独说。上下文不是我自己拼的 ------advisor 会在请求发出前自动拿 userInput 去 vectorStore 检索并注入,所以提示词里那句"这些信息通过矢量数据库检索得到"不是装饰,是在告诉模型"下面那段表结构是你刚查到的,要信"。第 6 条和第 1 条几乎同义,是故意的 :模型对"别输出 markdown"这类否定式约束遵守率不高,重复一次收益明显,而下游可是要把这段文本直接扔给 JDBC 的。用词要绝对化 ------"禁止输出"而不是"尽量不要","返回空字符串"而不是"可以返回空",宁可它什么都不返回,也不要它编一张不存在的表。blockLast() 不是 blockFirst() ,第 3 篇那个坑原样复现:流式响应是几十个小块,blockFirst() 只截第一块,链路能跑但永远为空,而且不抛异常。最后,返回的 key 必须和 keyStrategyFactory 对上。
跑通:GET http://127.0.0.1:8877/genSql/talk?userInput=计算2025年每个月的销售总额,并按月份升序排列
sql
SELECT d.month, SUM(s.sales_amount) AS total_sales
FROM fact_sales s
JOIN dim_date d ON s.date_id = d.date_id
WHERE d.year = 2025
GROUP BY d.month
ORDER BY d.month ASC;
一次通过。dim_date 猜对了,sales_amount(实收)也猜对了------靠的就是那 6 个块里"3.1 销售事实表"那行的字段注释。把这条 SQL 直接扔进客户端跑,各月合计与全表汇总一致。
十一、用户不会好好说话:Query 改写
真实上线后第一个坏消息:用户不按你的套路提问。 "找去年卖得好的产品"------两个坑:"去年"是相对时间,库里只有 2025 年 1 月的数据;"卖得好"没有定义,是销量、销售额还是毛利。直接把原话拿去向量化,检索基本靠缘分。解决办法是检索之前先改写查询,也就是自动提示工程。
Spring AI 留了标准扩展点 QueryTransformer(包 org.springframework.ai.rag.preretrieval.query.transformation,接口本身就一个 Query transform(Query query),apply() 默认委托给它),框架自带三个实现,全部靠大模型完成转换:
| 转换器 | 干什么 | 什么时候用 |
|---|---|---|
RewriteQueryTransformer |
把口语化提问重写成结构化、利于检索的查询,可指定 targetSearchSystem |
主力。自然语言转 SQL 这种对措辞极敏感的场景 |
CompressionQueryTransformer |
结合会话历史把冗长提问压缩成一句标准化查询 | 多轮对话,"那再看下华东的呢"这种指代 |
TranslationQueryTransformer |
按 translate it to {targetLanguage} 模板换语言 |
知识库是英文、用户问中文 |
改造 GenSQLNode,只需给顾问多挂两个环节(documentRetriever 那段不变):
java
RewriteQueryTransformer queryTransformer = RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuild).build(); // 检索前先改写
RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
.queryTransformers(queryTransformer)
.documentRetriever(...) // 同上一节,指向 vectorStore
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true) // 召回为空也不炸
.build())
.build();
这里有个连带改造:构造函数里的 ChatClient 要换成 ChatClient.Builder ,因为 RewriteQueryTransformer 自己也要调大模型,它要的是 Builder。allowEmptyContext(true) 我建议显式设------它默认 false,一旦什么都没召回到,顾问直接抛异常;而"召回到空"在生产里是常态,我们要的是让模型按提示词第 7 条老实返回空字符串,而不是让整条工作流红着退出。再往上一层还有 query → query₁, query₂, ..., queryₙ 的多路查询扩展 (一个问题扩成多个检索式再去重聚合)和 Query Router 分库路由,但我的判断是:先把分割器和改写做好,这两步的收益远大于堆架构。
十二、把检索链路下断点:源码到底怎么跑的
调优到后面光看文档不够了,我直接在 VectorStoreDocumentRetriever.retrieve() 下了断点。它实现 DocumentRetriever(extends Function<Query, List<Document>>),retrieve() 里做三件事:Assert.notNull(query, "query cannot be null") → computeRequestFilterExpression(query) 生成过滤条件 → 组一个 SearchRequest 交给 this.vectorStore.similaritySearch(...)。
调用栈从下往上捋:GenSqlController.talk → GenSQLNode.apply → advisor.before(...) → getDocumentsForQuery(内部就一行 documentRetriever.retrieve(query))→ MilvusVectorStore.similaritySearch → queryAugmenter.augment(originalQuery, documents) → prompt().augmentUserMessage(augmentedQuery)。最后一步值得记住:表结构是被拼进用户消息(不是 system)才发给模型的。
断点停下时,变量面板给出几个决定召回质量的关键值:this.similarityThreshold = {Double} 0.0、this.topK = {Integer} 4、this.vectorStore = {MilvusVectorStore}、query = {Query} "计算2025年每个月的销售总额,并按月份升序排列"。
这两个默认值非常重要,而且很容易一辈子不去看它们。 topK 默认 4 :我的知识库正好被切成 6 块,也就是说无论怎么问,最多只有 4 块能进上下文------想同时拿到"销售事实表 + 商品维度 + 时间维度 + 门店维度"就已顶格,再加顾客维度必然漏。similarityThreshold 默认 0.0,等于不设阈值:只要 topK 有名额,再不相干的块也会被塞进提示词。
召回结果在 Document 上是可验证的:命中的块带 score(我抓到的一条是 0.5186)和 metadata.source(BI_Table_Schema.docx)。7 个候选里筛出 4 个相关文档,销售事实表、商品维度、库存事实表、顾客维度各占一席。这条链路一旦在脑中有那张时序图,"为什么没召回到"就从玄学变成三步排查:块切得对不对 → 阈值和 topK 卡不卡 → 改写后的 query 还是不是原意。
十三、写完这一章,我改掉的几个认知
| 之前的想法 | 现在的事实 |
|---|---|
| RAG 是"接个向量库"的开关型功能 | 它是两条链路、七八个可独立劣化的环节 |
| 效果不好就换更大的模型 | 我 90% 的效果问题出在切分粒度 |
| 上下文窗口够大就不用分割器 | 窗口只解决"塞得下",不解决"找得准" |
| 提示词是玄学 | 它是可验证的工程约束:绝对化表述 + 重复强调 + 明确的失败出口 |
| 向量库随便选一个就行 | 维度、命名规则、TopK 默认值、可视化,每一项都会回头咬你 |
| 用户会按文档的写法提问 | 不会。Query 改写不是高级特性,是必需品 |
还有一点:RAG 让 Java 工程师的优势第一次真正显性化 了------数据建模、字段注释、链路可观测性这些"老派工程素养",反而决定了模型能不能拿到干净的上下文。大模型是执行者,上下文质量是架构师的责任。
下一篇进入第 10 章:Agent 的安全隐患与 SQL 执行层加固。这一章我们很开心地让模型生成了"可执行的 SQL"然后直接扔给 JDBC------谁来拦住那条 DROP TABLE?
本篇是我按课程讲义(第 9 章 18 讲)逐节复现整理的工程记录,代码依据文字稿与内嵌截图重建,未在本地完整运行验证;截图里的 API Key 一律以占位符替代,参数取值均以讲义与截图明确给出的数值为准。若 Spring AI 版本不同,相关 builder 方法名可能有出入。