导读
本文解决的问题
前置文档已经把知识块「向量化 + 建索引」写进了向量库。但写进去不等于能用——用户来一个问题,你怎么从库里把「最相关的几条」捞出来?
本文教你三件事,把「躺在库里的向量」变成「能答得上话的知识」:
- 语义召回(核心):把用户问题也向量化,和库里的向量算相似度,取最近的 topK 条;
- 召回调优:用
topK、similarityThreshold、filterExpression三把旋钮控制「召回多少、要多准、只召回哪些范围」; - 接进生成:把召回结果自动塞进大模型上下文,完成「召回 → 生成」的闭环(
RetrievalAugmentationAdvisor)。
做完这三步,「重排 → 生成」才有米下锅。
主线流程
用户 query → 向量化 → 相似度检索(+元数据过滤 / +BM25混合检索) → 命中 topK 条知识块 → 喂给大模型生成
↑____________________________ 本文覆盖 ____________________________↑
贯穿全文的例子还是那条「退货政策」:
库里存了这么一条知识:「7 天无理由退货:签收后 7 天内、未使用可申请,审核通过后 3~5 天退款。」(
category=售后)用户问:「这东西咋退啊?」 —— 你会看到它怎么命中上面那条书面化的知识,怎么控制召回数量和质量,怎么把命中的知识拼进 prompt 让大模型答出来。
术语速查
| 词 | 一句话 |
|---|---|
| 召回(Retrieval) | 从库里捞出与问题相关的候选知识,供后续使用 |
| 相似度检索 | 拿问题向量和库里向量算距离,取最近的几条 |
| topK | 最多返回几条结果 |
| 相似度阈值 | 相似度的「及格线」,低于它的直接丢弃 |
| 元数据过滤 | 按字段(来源 / 类目 / 时间)先筛一批,再做向量检索 |
| 倒排索引 | 「词 → 文档列表」的映射,支撑关键词精确检索 |
| BM25 | 基于「词频 × 逆文档频率」的关键词打分算法 |
| 混合检索 | 向量召回 + BM25 关键词召回,两路结果融合 |
| RRF | 倒数排名融合,把两路排名合成一路 |
| Advisor | 把「召回」等步骤自动挂到对话上的组件 |
| DocumentRetriever | 封装「召回」这个动作的接口 |
第一部分 · 基础概念
第 1 章 检索召回是什么、为什么重要
1.1 召回在整个 RAG 里的位置
RAG 的查询链路通常分三段:召回(Retrieval)→ 重排(Rerank)→ 生成(Generation)。
用户问题
│
▼
① 召回:从几万/几十万条知识里,粗筛出最像的 topK 条(本文讲这个)
│ topK 条(比如 4~10 条)
▼
② 重排:用一个更精细的模型把这 topK 条重新排序,挑最相关的几条
│ 最相关 2~3 条
▼
③ 生成:把这几条塞进 prompt,让大模型基于它们回答
- 召回负责「广撒网」:宁可多捞、别漏,所以又叫「粗排」。它的核心指标是召回率(该找到的有没有找到)。
- 重排负责「精挑」:在召回结果里再排序,核心指标是精确率(排在最前的准不准)。
- 生成负责「组织语言」:用重排后的资料回答问题。
1.2 召回决定 RAG 上限
一句话:召回捞不到,后面的重排、生成再牛也没用——因为压根没把答案喂给它。
大模型只能基于「你递给它的那几条知识」回答。如果你召回漏了「退货政策」这条,模型再聪明也只能说「我不知道」。所以业界常说:RAG 的效果上限由召回决定,重排和生成只是在这个上限内做文章。
这就是为什么本文单独花一篇讲召回——它是整套 RAG 里「决定生死」的一环。
1.3 召回怎么「算」出来的
回顾前置文档:向量化把「语义近不近」变成了「向量距离近不近」。所以召回的核心动作就是:
① 把用户问题 query 也向量化 → 得到问题向量
② 拿问题向量和库里所有向量算相似度(默认余弦)
③ 按相似度从高到低排序,取前 topK 条,返回对应的「原文 + 元数据」
⚠️ 新手最容易忽略的一点:检索时,用户的问题也会被向量化。所以入库时用的 Embedding 模型和检索时用的,必须是同一个(至少维度、语义空间要一致)。用一个模型入库、另一个模型检索,向量对不上,召回会静默失效。
第二部分 · 语义召回(核心)
第 2 章 最简召回:一行代码捞 topK
2.1 召回入口:VectorStore 接口
Spring AI 把所有向量库的「检索」能力统一在 VectorStore 接口上。不管底层是 pgvector 还是 Elasticsearch,检索代码一模一样:
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.document.Document;
<span>import</span> org.springframework.ai.vectorstore.SearchRequest;
<span>import</span> org.springframework.ai.vectorstore.VectorStore;
<span>import</span> org.springframework.stereotype.Service;
<span>import</span> java.util.List;
<span>/**
* 检索服务:用户提问后,从向量库召回最相关的 topK 条知识块。
* 实际注入的是 PgVectorStore / ElasticsearchVectorStore(由 starter 自动装配)。
*/</span>
<span>@Service</span>
<span>public</span> <span>class</span> <span>RetrievalService</span> {
<span>private</span> <span>final</span> VectorStore vectorStore; <span>// 面向接口,换库不改代码</span>
<span>public</span> <span>RetrievalService</span><span>(VectorStore vectorStore)</span> {
<span>this</span>.vectorStore = vectorStore;
}
<span>/** 最简召回:只给 query,其余用默认(topK=4、阈值=0 不过滤) */</span>
<span>public</span> List<Document> <span>search</span><span>(String question)</span> {
<span>return</span> vectorStore.similaritySearch(question);
}
<span>/** 显式召回:用 SearchRequest 精确控制 topK / 阈值 / 过滤 */</span>
<span>public</span> List<Document> <span>search</span><span>(String question, <span>int</span> topK, <span>double</span> threshold)</span> {
<span>return</span> vectorStore.similaritySearch(
SearchRequest.builder()
.query(question) <span>// 用户的原始问题</span>
.topK(topK) <span>// 最多返回几条</span>
.similarityThreshold(threshold) <span>// 低于该相似度的直接丢弃</span>
.build());
}
}
| 方法 | 说明 |
|---|---|
| `similaritySearch(String query)` | 便捷方法,等价于只设了 `query` 的 `SearchRequest`,topK 默认 4 |
| `similaritySearch(SearchRequest)` | 完整方法,可精确控制 topK / 阈值 / 过滤,**推荐** |
similaritySearch(String)底层就是similaritySearch(SearchRequest.builder().query(query).build())。写一次、跑通没问题;但要上生产,建议都用SearchRequest显式声明参数——默认值藏起来的东西,出问题不好定位。
2.2 检索返回什么:Document 带上了分数
召回返回 List<Document>,每一条都带三样东西:原文(getText())、元数据(getMetadata())、相似度分数(getScore()):
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(<span>"这东西咋退啊?"</span>).topK(<span>3</span>).build());
<span>for</span> (Document d : hits) {
System.out.println(<span>"相似度 "</span> + d.getScore() <span>// 命中分数,越大越相关</span>
+ <span>" | 来源 "</span> + d.getMetadata().get(<span>"source"</span>) <span>// 溯源</span>
+ <span>" | "</span> + d.getText()); <span>// 原文,喂给模型用的就是它</span>
}
预期输出(示意):
相似度 0.83 | 来源 return-policy.md | 7 天无理由退货:签收后 7 天内、未使用可申请……
相似度 0.71 | 来源 logistics.md | 退货后物流上门取件,运费由平台承担……
相似度 0.65 | 来源 faq.md | 退款到账时间一般为 3~5 个工作日……
⚠️ 分数只有相对意义,没有绝对意义。不同向量库、不同距离度量算出来的分数量纲不同(余弦是 0
1,欧氏距离是 0+∞),甚至同一库不同查询的分数也不可横比。别拿分数当硬门槛去写死判断,它只用来「在这一批结果里排序」。
2.3 检索会「先向量化,再算距离」
similaritySearch 内部做的,就是 1.3 那张图的三步。拆开看:
vectorStore.similaritySearch(request)
├─ ① 对 query 调 embeddingModel.embed(query) → 问题变成向量
├─ ② 问题向量和库里所有向量算相似度(余弦,走 HNSW 索引加速)
└─ ③ 按分数排序 → 套用 topK/阈值/过滤 → 返回 List<Document>
所以你不需要手动对 query 做向量化,框架都包好了。这也是为什么「入库模型」和「检索模型」必须一致——第 ① 步用的 Embedding 模型,就是入库时 VectorStore.add() 内部用的那个。
第 3 章 控制召回:SearchRequest 的三把旋钮
SearchRequest是召回请求的「参数包」。三把旋钮:topK(召回多少)、similarityThreshold(要多准)、filterExpression(只召回哪些范围,第 4 章讲)。
3.1 topK:最多返回几条
<span>// 只要最像的 1 条(最适合"精确问答")</span>
SearchRequest.builder().query(<span>"退货要几天"</span>).topK(<span>1</span>).build();
<span>// 要 10 条(适合"需要综合多段资料"的开放问题)</span>
SearchRequest.builder().query(<span>"售后流程是怎样的"</span>).topK(<span>10</span>).build();
| 取值 | 效果 | 适用 |
|---|---|---|
| 小(1~2) | 只给最准的一条 | 精确事实问答、FAQ |
| 中(4~6,默认 4) | 平衡召回与噪声 | 通用问答 |
| 大(8~10+) | 资料更全、但噪声多 | 综述类、多知识点问题 |
topK 不是越大越好:召回越多,塞进模型的无关内容越多,反而稀释注意力、更容易「幻觉」。RAG 里「宁缺毋滥」通常比「宁滥勿缺」好——先 4 条起步,不够再加。
3.2 similarityThreshold:相似度「及格线」
<span>// 只要相似度 >= 0.7 的结果(低于 0.7 的哪怕凑不满也一律不要)</span>
SearchRequest.builder()
.query(<span>"退货要几天"</span>)
.topK(<span>5</span>)
.similarityThreshold(<span>0.7</span>)
.build();
topK 和 similarityThreshold 是叠加关系,不是二选一:先按阈值筛掉低分的,再从剩下的里取前 topK 条。
库里按相似度排序:0.85, 0.82, 0.78, 0.61, 0.55, 0.43 ...
↑ 阈值 0.7 这条线
筛掉 0.61/0.55/0.43 → 只剩 3 条 → 取前 5 条 = 只返回 3 条
阈值是把双刃剑:设太高(如 0.9),会「该召回的也召回不到」;设太低(如 0.3),等于没过滤,噪声全进来了。
经验值:余弦相似度下,0.5~0.7 是常见区间——0.5 偏召回(宁可多召回),0.7 偏精确(宁缺毋滥)。具体值要结合你的 Embedding 模型和语料实测,别照抄。不确定就先不设(默认 0.0 不过滤),跑通后看分数分布再定。
3.3 三把旋钮串起来
<span>SearchRequest</span> <span>request</span> <span>=</span> SearchRequest.builder()
.query(<span>"退货要几天"</span>) <span>// 问题</span>
.topK(<span>5</span>) <span>// 最多 5 条</span>
.similarityThreshold(<span>0.6</span>) <span>// 低于 0.6 不要</span>
.filterExpression(...) <span>// 第 4 章讲:按元数据先筛</span>
.build();
List<Document> hits = vectorStore.similaritySearch(request);
第 4 章 元数据过滤:FilterExpressionBuilder
前置文档第 3.5 节在入库时写进了
source、category、chunk_no等元数据。这一节把它们用起来——检索时按元数据先筛一批,再做向量相似度。
4.1 为什么要过滤
假设你的知识库同时存了「售后退货」和「商品参数」两类知识。用户问「退货要几天」,如果不过滤,向量检索可能把「商品参数」里语义沾边的也捞出来,干扰答案。元数据过滤 = 先框定范围,再在范围内找最像的:
不过滤:在全库 10 万条里找 → 可能混入别的领域的"近义词"
过滤后:先在 category=售后 的 8000 条里找 → 范围对,召回更准
它也是多租户隔离、按时间/来源检索的基础能力。
4.2 DSL 用法
Spring AI 用 FilterExpressionBuilder 拼过滤条件,返回 Filter.Expression 传给 filterExpression:
<span>import</span> org.springframework.ai.vectorstore.filter.Filter;
<span>import</span> org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
<span>FilterExpressionBuilder</span> <span>b</span> <span>=</span> <span>new</span> <span>FilterExpressionBuilder</span>();
<span>// ① 单条件:只要"售后"类目</span>
Filter.<span>Expression</span> <span>onlyAfterSale</span> <span>=</span> b.eq(<span>"category"</span>, <span>"售后"</span>);
<span>// ② 多条件 AND:售后 且 块序号 >= 2</span>
Filter.<span>Expression</span> <span>afterSaleAndLater</span> <span>=</span> b.and(
b.eq(<span>"category"</span>, <span>"售后"</span>),
b.gte(<span>"chunk_no"</span>, <span>2</span>)
);
<span>// ③ 嵌套: (category==售后 OR category==物流) 且 来源不是草稿</span>
Filter.<span>Expression</span> <span>complex</span> <span>=</span> b.and(
b.group(b.or(b.eq(<span>"category"</span>, <span>"售后"</span>), b.eq(<span>"category"</span>, <span>"物流"</span>))),
b.ne(<span>"source"</span>, <span>"draft.md"</span>)
);
<span>// 用上过滤:过滤和检索一起下推,先筛后查</span>
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder()
.query(<span>"退货要几天?"</span>)
.topK(<span>5</span>)
.filterExpression(onlyAfterSale) <span>// 只在这个范围内召回</span>
.build());
4.3 运算符速查
| 运算符 | 方法 | 例子 |
|---|---|---|
| 等于 | `eq` | `b.eq("category", "售后")` |
| 不等于 | `ne` | `b.ne("status", "draft")` |
| 大于 / 大于等于 | `gt` / `gte` | `b.gte("year", 2024)` |
| 小于 / 小于等于 | `lt` / `lte` | `b.lt("price", 100)` |
| 在…之中 | `in` | `b.in("tag", "退款", "退货")` |
| 不在…之中 | `nin` | `b.nin("lang", "en")` |
| 与 | `and` | `b.and(exp1, exp2, ...)` |
| 或 | `or` | `b.or(exp1, exp2, ...)` |
| 非 | `not` | `b.not(exp)` |
| 分组 | `group` | `b.group(exp)`(加括号,控制优先级) |
这些方法直接返回
Filter.Expression,可直接赋给filterExpression;group()用来给复杂条件加括号,避免and/or优先级出错。
4.4 字符串表达式(等价写法)
不习惯 DSL 也可以直接写 SQL 风格的字符串,语义完全等价:
SearchRequest.builder()
.query(<span>"退货要几天?"</span>)
.topK(<span>5</span>)
.filterExpression(<span>"category == '售后' && chunk_no >= 2"</span>) <span>// 字符串写法</span>
.build();
支持的写法:==、!=、>、>=、<、<=、&&、||、in ['a','b']。
DSL vs 字符串怎么选:字符串适合简单条件(一眼能看懂);条件一复杂、或字段名是变量拼接,用 DSL——它帮你做类型安全,避免手拼字符串出错或注入。
4.5 三个关键约束
⚠️ 过滤的 key 必须和入库时 metadata 的 key 完全一致(大小写、命名都一致)。入库写的是
category,检索写category能命中,写Category就静默失效——不报错,但永远筛不到。⚠️ 值类型要对上:metadata 里
chunk_no存的是数字,过滤就要gte("chunk_no", 2)传数字;存的是字符串,就传字符串。类型不一致,不同向量库表现不同(报错或静默失效)。⚠️ 过滤是「预过滤」:框架会先把 filter 下推到向量库,筛掉一批候选,再做向量相似度。所以过滤不仅能提准,还能提快(算相似度的候选少了)。
第三部分 · 倒排索引 + 混合检索
第 5 章 关键词召回:BM25 与混合检索
前置文档 3.2 节埋了个伏笔:向量索引管「语义相近」,倒排索引管「关键词精确匹配」。这一节把它展开——什么时候只靠向量不够,怎么把倒排索引(BM25)加进来。
5.1 纯向量检索的盲区
向量检索擅长「语义相近」,但对两类东西抓不准:
- 专有名词、精确术语:SKU 编号
RT-2024-8890、型号iPhone 15 Pro、人名、法律条文编号……这些词库里可能只出现一两次,向量模型对它们「没见过 / 记不牢」,相似度算出来经常不是最高; - 字面精确匹配:用户就是想搜「3 天退款」这四个字,向量可能召回「7 天退款」「5 个工作日到账」等语义相近但字面不同的。
一句话:向量看「意思像不像」,倒排索引看「字面有没有」。用户问「退货政策第 7 条」,你要的是「第 7 条」这串字精确命中,而不是「和退货相关的所有东西」。
5.2 倒排索引 + BM25 回顾
前置文档讲过倒排索引怎么建:把「文档 → 词」反转为「词 → 文档列表」。检索时按词定位文档,再用 BM25 打分:
BM25 打分 = 词频(一个词在这篇文档出现几次)× 逆文档频率(这个词有多"稀有")
「RT-2024-8890」这个编号:几乎只出现在 1 篇文档里 → 逆文档频率极高 → 谁搜它,那一篇稳居第一
所以 BM25 特别擅长稀有精确词的命中——恰好补上向量的短板。
5.3 混合检索:两路召回 + 融合
最优解是两条腿走路,各取所长:
用户 query ──┬── 向量路:query 向量 vs 库向量 → 语义相似 topK 条
│
└── 关键词路:query 分词 → 倒排索引 → BM25 打分 topK 条
│
┌───────┴────────┐
▼ ▼
两路结果做融合(RRF / 加权)→ 合并去重后的最终 topK 条
为什么要融合:两路返回的分数量纲完全不同——向量相似度是 01,BM25 分数可能 0几十。直接比大小没法比,所以用 RRF(Reciprocal Rank Fusion,倒数排名融合):
RRF 分数(文档 d) = Σ 1 / (k + rank_i(d))
rank_i(d):文档 d 在第 i 路结果里的排名(第 1 名 = 1,第 2 名 = 2……)
k:平滑常数,常见取 60
两路都把文档排了名 → 每个文档拿一个"倒数排名分" → 两路分数相加 → 按总分重排
具体例子(还是退货知识库):用户问「7 天退货后多久能退款?」,两路各自召回 topK=3。注意,两路的分数只用来排「本路内部的序」,不参与融合。
① 向量路(语义)排名:
| 排名 | 文档 | 内容摘要 | 相似度 |
|---|---|---|---|
| 1 | doc\_B | 退款将在审核通过后 3~5 个工作日内原路退回 | 0.81 |
| 2 | doc\_A | 7 天无理由退货…3~5 天退款 | 0.76 |
| 3 | doc\_D | 签收后 7 天内可申请无理由退货… | 0.70 |
② 关键词路(BM25)排名:
| 排名 | 文档 | 内容摘要 | BM25 |
|---|---|---|---|
| 1 | doc\_A | 7 天无理由退货…3~5 天退款 | 9.2 |
| 2 | doc\_D | 签收后 7 天内可申请无理由退货… | 8.1 |
| 3 | doc\_C | 退货申请通过后,平台安排上门取件 | 5.5 |
两路分数完全不可比(向量 0.70
0.81,BM25 5.59.2),且结果还不一样:doc_B语义最像但没出现「7 天退货」字眼,只有向量路捞到;doc_C有「退货」字但语义弱,只有关键词路捞到。直接合并根本没法排。
③ 套 RRF 公式(k=60):
| 文档 | 向量路 rank | 关键词路 rank | RRF 计算 | RRF 总分 |
|---|---|---|---|---|
| doc\_A | 2 | 1 | 1/(60+2) + 1/(60+1) | **0.0325** |
| doc\_D | 3 | 2 | 1/(60+3) + 1/(60+2) | **0.0320** |
| doc\_B | 1 | —(未进) | 1/(60+1) + 0 | **0.0164** |
| doc\_C | —(未进) | 3 | 0 + 1/(60+3) | **0.0159** |
④ 融合后的最终排序:
1. doc_A (0.0325) ← 两路都命中,稳居第一(正是完整答案)
2. doc_D (0.0320) ← 两路都命中
3. doc_B (0.0164) ← 仅向量路命中(语义像、字面缺)
4. doc_C (0.0159) ← 仅关键词路命中(有字面词、语义弱)
看懂这个例子,就懂了混合检索的价值:只靠向量路,doc_A 会排在 doc_B 后面,精确的「7 天退货」反而被稀释;只靠关键词路,doc_B 这种语义等价、字面不同的知识根本捞不到;而 RRF 把「两路都认可」的 doc_A 提到第一,又保住了「只有一路命中」的 doc_B、doc_C 不被漏掉。
RRF 只关心「排名」,不关心「绝对分数」,天然绕开了两路分数量纲不同的问题,是混合检索的默认融合策略。
5.4 落地载体:Elasticsearch
前面说的「混合检索」要落地,Elasticsearch 最省事——它一套引擎里同时有向量索引和倒排索引。但动手前必须先划清一条界限,否则很容易写出「以为在做混合检索、其实只做了元数据过滤」的代码。
① 先分清两件事
| 能力 | 是什么 | 谁提供 | 在检索里的角色 |
|---|---|---|---|
| 元数据过滤(`filterExpression`) | 按 `category`/`source` 等字段**筛候选**,不做正文打分 | 框架自带,是 `SearchRequest` 的一个参数 | 向量检索的**附加过滤条件** |
| 正文 BM25 关键词检索 | 按正文**词频算相关性、排序召回** | 框架**没有**封装,要自己调 ES | **完全独立的一套检索**(见 5.5) |
一句话划清界限:元数据过滤是「筛」,BM25 是「打分排序」。
- 元数据字段也可以有索引(ES 用倒排索引、PG 用 B 树),但那是为了快速定位候选,不参与相关性打分;
- 元数据过滤是向量检索的附加能力:
SearchRequest直接传 filter,底层向量库在向量检索时顺带过滤 metadata,只执行一次检索请求;- BM25 则是根据正文词频算相关性、排序召回,是完全独立的另一套检索。框架不会帮你自动「向量 KNN + BM25 一起跑」,也没有内置 RRF 融合(这些都在 5.5)。
② 依赖 + 配置(完整版)
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-starter-vector-store-elasticsearch<span></<span>artifactId</span>></span>
<span></<span>dependency</span>></span>
<span>spring:</span>
<span>elasticsearch:</span>
<span>uris:</span> <span>http://localhost:9200</span>
<span>username:</span> <span>elastic</span>
<span>password:</span> <span>${ES_PASSWORD}</span>
<span>ai:</span>
<span>dashscope:</span>
<span>api-key:</span> <span>${DASHSCOPE_API_KEY}</span>
<span>embedding:</span>
<span>options:</span>
<span>model:</span> <span>text-embedding-v3</span>
<span>dimensions:</span> <span>1024</span>
<span>vectorstore:</span>
<span>elasticsearch:</span>
<span>initialize-schema:</span> <span>true</span> <span># 自动建索引</span>
<span>index-name:</span> <span>rag-doc-index</span>
<span>dimensions:</span> <span>1024</span> <span># 与 embedding 维度一致</span>
<span>similarity:</span> <span>cosine</span>
initialize-schema: true建库时,embedding声明成dense_vector(向量索引载体)、content是 text 字段(正文倒排索引载体,5.5 的 BM25 用它)、metadata是 object 字段。
③ 完整例子:一次向量检索,顺带元数据过滤
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.document.Document;
<span>import</span> org.springframework.ai.vectorstore.SearchRequest;
<span>import</span> org.springframework.ai.vectorstore.VectorStore;
<span>import</span> org.springframework.stereotype.Service;
<span>import</span> java.util.List;
<span>/**
* ES 语义检索。注意:从头到尾只有「一次向量检索请求」。
* filterExpression 是这次向量查询里的一个参数,不是另一套检索——
* 底层在算向量相似度时顺带过滤 metadata,先筛候选、再在候选里排序。
*/</span>
<span>@Service</span>
<span>public</span> <span>class</span> <span>EsRetrievalService</span> {
<span>private</span> <span>final</span> VectorStore vectorStore; <span>// 自动装配的是 ElasticsearchVectorStore</span>
<span>public</span> <span>EsRetrievalService</span><span>(VectorStore vectorStore)</span> {
<span>this</span>.vectorStore = vectorStore;
}
<span>public</span> List<Document> <span>search</span><span>(String question)</span> {
<span>return</span> vectorStore.similaritySearch(
SearchRequest.builder()
.query(question) <span>// 向量检索:语义排序</span>
.topK(<span>5</span>) <span>// 取最近 5 条</span>
.filterExpression(<span>"category == '售后'"</span>) <span>// 顺带过滤:先筛候选、再排序</span>
.build());
}
}
强调:上面只发了一次检索请求。
filterExpression是这次向量检索的参数——框架把它下推给 ES,ES 先用元数据索引筛掉不匹配的候选,再在剩余候选里做向量相似度排序。这不是两套检索,更不是 BM25。
④ 那「正文 BM25」在哪?
| 想做的事 | 位置 | 要不要自己写 |
|---|---|---|
| 语义召回 + 元数据过滤(上面) | 5.4 本节 | 不用,一个 `SearchRequest` 搞定 |
| 正文 BM25 关键词检索 | 5.5 | 要,框架没封装,自己调 ES |
| 向量 + BM25 + RRF 混合检索 | 5.5 | 要,自己写融合 |
一句话:元数据过滤是框架白送的,BM25 得自己动手。 下一节(5.5)给出「向量 + BM25 + RRF」的完整实现。
5.5 混合检索落地:向量 + BM25 + RRF(纯业务实现)
先记牢三个事实:
- Spring AI Alibaba 1.1.2.2 的标准向量库,只封装了向量检索(外加
filterExpression元数据过滤这个附带参数);- 它没有内置 BM25 正文关键词检索,也没有内置 RRF 融合——框架不会帮你自动「向量 KNN + BM25」两路一起跑;
- 所以混合检索要自己动手三步:① 调向量检索(框架自带)→ ② 调 ES 的 BM25(原生客户端)→ ③ 写 RRF 融合(纯 Java)。
下面参考 DataAgent 的 ES 混合检索思想,剥离业务,只留核心三步。不依赖 DataAgent,纯业务实现,复用 Spring AI 原生
Document。
前置:Elasticsearch 8.x;索引里每篇文档同时存 embedding(dense_vector,向量索引)和 content(text,正文倒排索引)两个字段。用 5.4 的配置建库即可。
方案 B:普通 Service(简单直接,完全脱离 Advisor)
<span>package</span> com.example.rag;
<span>import</span> co.elastic.clients.elasticsearch.ElasticsearchClient;
<span>import</span> co.elastic.clients.elasticsearch.core.SearchResponse;
<span>import</span> co.elastic.clients.elasticsearch.core.search.Hit;
<span>import</span> com.fasterxml.jackson.core.type.TypeReference;
<span>import</span> com.fasterxml.jackson.databind.ObjectMapper;
<span>import</span> com.fasterxml.jackson.databind.node.ObjectNode;
<span>import</span> org.springframework.ai.document.Document;
<span>import</span> org.springframework.ai.vectorstore.SearchRequest;
<span>import</span> org.springframework.ai.vectorstore.VectorStore;
<span>import</span> org.springframework.stereotype.Service;
<span>import</span> java.util.ArrayList;
<span>import</span> java.util.LinkedHashMap;
<span>import</span> java.util.List;
<span>import</span> java.util.Map;
<span>/**
* 混合检索(方案 B):普通 Service,完全脱离 Advisor,简单直接。
*
* 三步:① 向量检索(框架自带) ② BM25 关键词检索(ES 原生) ③ RRF 融合(纯 Java)。
* 前置:ES 8.x,索引同时存 dense_vector 向量字段 + content 正文文本字段。
* 不依赖 DataAgent,纯业务实现,复用 Spring AI 原生 Document。
*/</span>
<span>@Service</span>
<span>public</span> <span>class</span> <span>HybridSearchService</span> {
<span>private</span> <span>static</span> <span>final</span> <span>int</span> <span>RRF_K</span> <span>=</span> <span>60</span>; <span>// RRF 平滑常数(5.3 公式里的 k)</span>
<span>private</span> <span>static</span> <span>final</span> <span>String</span> <span>INDEX_NAME</span> <span>=</span> <span>"rag-doc-index"</span>; <span>// 与 5.4 配置一致</span>
<span>private</span> <span>static</span> <span>final</span> <span>String</span> <span>CONTENT_FIELD</span> <span>=</span> <span>"content"</span>; <span>// Spring AI ES 默认正文字段名</span>
<span>private</span> <span>final</span> VectorStore vectorStore; <span>// 向量路:similaritySearch</span>
<span>private</span> <span>final</span> ElasticsearchClient esClient; <span>// BM25 路:ES 原生 match(Spring Boot 自动装配)</span>
<span>private</span> <span>final</span> ObjectMapper objectMapper; <span>// 还原 metadata 用</span>
<span>public</span> <span>HybridSearchService</span><span>(VectorStore vectorStore, ElasticsearchClient esClient, ObjectMapper objectMapper)</span> {
<span>this</span>.vectorStore = vectorStore;
<span>this</span>.esClient = esClient;
<span>this</span>.objectMapper = objectMapper;
}
<span>/** 混合检索入口:向量 KNN + BM25 关键词 + RRF 融合 */</span>
<span>public</span> List<Document> <span>hybridSearch</span><span>(String question, <span>int</span> topK)</span> {
<span>// ① 向量检索:框架自带,一次请求(可顺带 filterExpression 元数据过滤,见 5.4)</span>
List<Document> vectorHits = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(topK).build());
<span>// ② BM25 关键词检索:独立的一次 ES 请求,走正文倒排索引</span>
List<Document> bm25Hits = bm25Search(question, topK);
<span>// ③ RRF 融合:两路排名合成一路</span>
<span>return</span> rrf(vectorHits, bm25Hits, topK, RRF_K);
}
<span>/** ② BM25:对正文 content 字段发 match 查询,按词频相关性打分,还原成 Document */</span>
<span>private</span> List<Document> <span>bm25Search</span><span>(String question, <span>int</span> topK)</span> {
SearchResponse<ObjectNode> response = esClient.search(s -> s
.index(INDEX_NAME)
.query(q -> q.match(m -> m.field(CONTENT_FIELD).query(question))) <span>// 正文 BM25</span>
.size(topK),
ObjectNode.class);
List<Document> hits = <span>new</span> <span>ArrayList</span><>();
<span>for</span> (Hit<ObjectNode> hit : response.hits().hits()) {
<span>ObjectNode</span> <span>source</span> <span>=</span> hit.source();
<span>if</span> (source == <span>null</span>) {
<span>continue</span>;
}
<span>String</span> <span>text</span> <span>=</span> source.get(CONTENT_FIELD).asText(); <span>// 正文</span>
Map<String, Object> metadata = objectMapper.convertValue( <span>// 元数据还原</span>
source.get(<span>"metadata"</span>), <span>new</span> <span>TypeReference</span><Map<String, Object>>() {});
<span>Document</span> <span>doc</span> <span>=</span> <span>new</span> <span>Document</span>(text, metadata);
doc.setId(hit.id()); <span>// ES _id,用于两路去重</span>
doc.setScore(hit.score() == <span>null</span> ? <span>0.0</span> : hit.score()); <span>// BM25 原始分</span>
hits.add(doc);
}
<span>return</span> hits;
}
<span>/**
* ③ RRF 倒数排名融合:只认「排名」不认「分数」,兼容两路分数量纲不同。
* 两路都命中的文档,两个倒数分相加,排得更靠前。
*/</span>
<span>private</span> List<Document> <span>rrf</span><span>(List<Document> vectorHits, List<Document> bm25Hits, <span>int</span> topK, <span>int</span> k)</span> {
Map<String, Double> rrfScore = <span>new</span> <span>LinkedHashMap</span><>(); <span>// id -> 累加 RRF 分</span>
Map<String, Document> docById = <span>new</span> <span>LinkedHashMap</span><>(); <span>// id -> Document(去重)</span>
<span>// 向量路:rank 从 1 开始</span>
<span>for</span> (<span>int</span> <span>i</span> <span>=</span> <span>0</span>; i < vectorHits.size(); i++) {
<span>Document</span> <span>d</span> <span>=</span> vectorHits.get(i);
rrfScore.merge(d.getId(), <span>1.0</span> / (k + i + <span>1</span>), Double::sum);
docById.putIfAbsent(d.getId(), d);
}
<span>// BM25 路</span>
<span>for</span> (<span>int</span> <span>i</span> <span>=</span> <span>0</span>; i < bm25Hits.size(); i++) {
<span>Document</span> <span>d</span> <span>=</span> bm25Hits.get(i);
rrfScore.merge(d.getId(), <span>1.0</span> / (k + i + <span>1</span>), Double::sum);
docById.putIfAbsent(d.getId(), d);
}
<span>// 按 RRF 分降序,取前 topK</span>
<span>return</span> rrfScore.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> {
<span>Document</span> <span>d</span> <span>=</span> docById.get(e.getKey());
d.setScore(e.getValue()); <span>// 融合分写回 score,便于观察</span>
<span>return</span> d;
})
.toList();
}
}
三步对应:
hybridSearch()里 ①vectorStore.similaritySearch(向量)→ ②bm25Search()(ESmatch走正文倒排)→ ③rrf()(倒数排名融合)。BM25 那路用ElasticsearchClient(Spring Boot 按spring.elasticsearch.*自动装配,spring-ai-starter-vector-store-elasticsearch已传递引入elasticsearch-java),对content字段发match查询,命中还原成Document(带上 id,用于两路去重)。
方案 A:自定义 DocumentRetriever(接入 Advisor 链路)
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.document.Document;
<span>import</span> org.springframework.ai.rag.Query;
<span>import</span> org.springframework.ai.rag.retrieval.search.DocumentRetriever;
<span>import</span> org.springframework.stereotype.Component;
<span>import</span> java.util.List;
<span>/**
* 混合检索(方案 A):自定义 DocumentRetriever,可接入 RetrievalAugmentationAdvisor。
* 复用方案 B 的 HybridSearchService,把「混合检索」包装成 Advisor 认识的「召回」动作。
*/</span>
<span>@Component</span>
<span>public</span> <span>class</span> <span>HybridDocumentRetriever</span> <span>implements</span> <span>DocumentRetriever</span> {
<span>private</span> <span>static</span> <span>final</span> <span>int</span> <span>TOP_K</span> <span>=</span> <span>4</span>;
<span>private</span> <span>final</span> HybridSearchService hybridSearchService;
<span>public</span> <span>HybridDocumentRetriever</span><span>(HybridSearchService hybridSearchService)</span> {
<span>this</span>.hybridSearchService = hybridSearchService;
}
<span>/** DocumentRetriever 唯一方法:入参 Query(含用户问题),出参召回结果 */</span>
<span>@Override</span>
<span>public</span> List<Document> <span>retrieve</span><span>(Query query)</span> {
<span>return</span> hybridSearchService.hybridSearch(query.text(), TOP_K);
}
}
接线(替换第 6 章那个基于 VectorStoreDocumentRetriever 的 bean):
<span>import</span> org.springframework.ai.rag.RetrievalAugmentationAdvisor;
<span>import</span> org.springframework.ai.rag.retrieval.search.DocumentRetriever;
<span>import</span> org.springframework.context.annotation.Bean;
<span>import</span> org.springframework.context.annotation.Configuration;
<span>@Configuration</span>
<span>public</span> <span>class</span> <span>RagConfig</span> {
<span>/** 把自定义的混合检索器挂进 Advisor */</span>
<span>@Bean</span>
<span>public</span> RetrievalAugmentationAdvisor <span>ragAdvisor</span><span>(DocumentRetriever documentRetriever)</span> {
<span>return</span> RetrievalAugmentationAdvisor.builder()
.documentRetriever(documentRetriever) <span>// 注入的是 HybridDocumentRetriever</span>
.build();
}
}
这样
ChatClient每次.call()时,HybridDocumentRetriever.retrieve()就会被调用,自动走「向量 + BM25 + RRF」混合召回,再喂给生成。
两方案怎么选
| 方案 | 特点 | 适用 |
|---|---|---|
| B:普通 Service | 直接调 `hybridSearch()`,返回 `List | 只要召回结果,或要接重排 / 自定义逻辑 |
| A:DocumentRetriever | 包装成 `DocumentRetriever`,挂进 `RetrievalAugmentationAdvisor` | 想用 `ChatClient` 一条龙「召回 → 生成」 |
两方案共用同一套核心逻辑,A 只是把 B 的
hybridSearch()包成DocumentRetriever.retrieve(),二选一即可。别同时声明两个DocumentRetriever(会和第 6 章的VectorStoreDocumentRetriever冲突)。
第四部分 · 把召回接进生成
第 6 章 RAG 检索增强:RetrievalAugmentationAdvisor
前面几章都在「手动召回」:自己调
similaritySearch,自己把结果拼进 prompt。这一节把它自动化——用一个Advisor,让每次对话自动完成「召回 → 注入上下文 → 生成」。
6.1 先看「手动版」长什么样
为了理解 Advisor 干了什么,先看手动流程:
<span>import</span> org.springframework.ai.document.Document;
<span>import</span> org.springframework.ai.vectorstore.SearchRequest;
<span>import</span> org.springframework.ai.vectorstore.VectorStore;
<span>import</span> java.util.List;
<span>import</span> java.util.stream.Collectors;
<span>/** 手动版:召回 → 拼 prompt → 交给大模型(Advisor 就是把这几步自动化) */</span>
<span>public</span> String <span>askManual</span><span>(VectorStore vectorStore, String question)</span> {
<span>// ① 召回</span>
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(<span>4</span>).build());
<span>// ② 把召回原文拼成"上下文"</span>
<span>String</span> <span>context</span> <span>=</span> hits.stream()
.map(Document::getText)
.collect(Collectors.joining(<span>"\n\n"</span>));
<span>// ③ 拼进 prompt,交给模型(chatModel.call 的伪代码,见 6.3 的完整版)</span>
<span>String</span> <span>prompt</span> <span>=</span> <span>"请仅基于以下资料回答问题,资料里没有就直说不知道。\n资料:\n"</span>
+ context + <span>"\n\n问题:"</span> + question;
<span>return</span> <span>/* chatModel.call(prompt) */</span> <span>""</span>;
}
三件事:召回、拼上下文、调用模型。每一步都可能出错,且每个问法都要重复写一遍。Advisor 把它封装成一个可复用的「对话增强器」。
6.2 三个组件:DocumentRetriever → Advisor → ChatClient
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.rag.RetrievalAugmentationAdvisor;
<span>import</span> org.springframework.ai.rag.retrieval.search.DocumentRetriever;
<span>import</span> org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
<span>import</span> org.springframework.ai.vectorstore.VectorStore;
<span>import</span> org.springframework.context.annotation.Bean;
<span>import</span> org.springframework.context.annotation.Configuration;
<span>@Configuration</span>
<span>public</span> <span>class</span> <span>RagConfig</span> {
<span>/** 文档检索器:封装"从向量库召回 topK"这一步(对应 6.1 的 ①②) */</span>
<span>@Bean</span>
<span>public</span> DocumentRetriever <span>documentRetriever</span><span>(VectorStore vectorStore)</span> {
<span>return</span> VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore) <span>// 从哪个库查</span>
.topK(<span>4</span>) <span>// 召回条数(默认 4)</span>
.similarityThreshold(<span>0.5</span>) <span>// 相似度门槛,低于则丢弃</span>
<span>// .filterExpression(...) // 可选:按元数据过滤(第 4 章)</span>
.build();
}
<span>/** 检索增强 Advisor:把"召回 + 注入上下文"自动挂到每次对话上(对应 6.1 全流程) */</span>
<span>@Bean</span>
<span>public</span> RetrievalAugmentationAdvisor <span>ragAdvisor</span><span>(DocumentRetriever documentRetriever)</span> {
<span>return</span> RetrievalAugmentationAdvisor.builder()
.documentRetriever(documentRetriever)
.build();
}
}
| 组件 | 职责 | 对应手动版的哪步 |
|---|---|---|
| `DocumentRetriever` | 只管「召回」 | ① 召回 |
| `RetrievalAugmentationAdvisor` | 编排「召回 → 拼上下文 → 生成」 | ①②③ 全流程 |
| `ChatClient` | 承载 Advisor 的对话客户端 | ③ 调用模型 |
这里把「召回」单独抽成
DocumentRetriever,是因为它和VectorStore是不同抽象层次:VectorStore既能读也能写(入库+检索),DocumentRetriever只读。给 RAG 链路只暴露一个只读的检索器,符合「最小权限」原则,也方便以后把「向量检索」换成「混合检索」「多路检索」而不用动 Advisor。
6.3 用 ChatClient 收口
ChatClient 是 Spring AI 的对话编排入口,把 Advisor 挂上去后,chatClient.prompt().user(...).call() 就会自动先召回再生成:
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.chat.client.ChatClient;
<span>import</span> org.springframework.ai.rag.RetrievalAugmentationAdvisor;
<span>import</span> org.springframework.context.annotation.Bean;
<span>import</span> org.springframework.context.annotation.Configuration;
<span>@Configuration</span>
<span>public</span> <span>class</span> <span>ChatClientConfig</span> {
<span>/** 把 RAG Advisor 设为默认,之后每次 .call() 都自动"召回 → 生成" */</span>
<span>@Bean</span>
<span>public</span> ChatClient <span>chatClient</span><span>(ChatClient.Builder builder, RetrievalAugmentationAdvisor ragAdvisor)</span> {
<span>return</span> builder
.defaultAdvisors(ragAdvisor) <span>// 默认挂上,所有调用都生效</span>
.build();
}
}
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.chat.client.ChatClient;
<span>import</span> org.springframework.stereotype.Service;
<span>/** 问答服务:一行 .call(),内部自动完成"召回 → 注入上下文 → 生成" */</span>
<span>@Service</span>
<span>public</span> <span>class</span> <span>QaService</span> {
<span>private</span> <span>final</span> ChatClient chatClient;
<span>public</span> <span>QaService</span><span>(ChatClient chatClient)</span> {
<span>this</span>.chatClient = chatClient;
}
<span>public</span> String <span>ask</span><span>(String question)</span> {
<span>return</span> chatClient.prompt()
.user(question) <span>// 用户问题</span>
.call() <span>// 触发:召回 → 拼上下文 → 调模型</span>
.content(); <span>// 取回答文本</span>
}
}
对比 6.1 手动版:业务代码只剩一行
.call(),召回、拼 prompt 全被 Advisor 收走了。这就是从「手写 RAG」到「声明式 RAG」的转变。
6.4 召回为空怎么办:ContextualQueryAugmenter
万一召回为空(用户问了个库里没有的问题),默认可能直接报错或答得莫名其妙。加一个 ContextualQueryAugmenter 兜底:
<span>import</span> org.springframework.ai.rag.RetrievalAugmentationAdvisor;
<span>import</span> org.springframework.ai.rag.postretrieval.generation.ContextualQueryAugmenter;
<span>import</span> org.springframework.ai.rag.retrieval.search.DocumentRetriever;
<span>@Bean</span>
<span>public</span> RetrievalAugmentationAdvisor <span>ragAdvisor</span><span>(DocumentRetriever documentRetriever)</span> {
<span>return</span> RetrievalAugmentationAdvisor.builder()
.documentRetriever(documentRetriever)
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(<span>true</span>) <span>// 召回为空时不报错,让模型如实回答"不知道"</span>
.build())
.build();
}
这是生产里很容易漏的一环:知识库总有覆盖不到的问题。开了
allowEmptyContext(true),模型会基于「无资料」的指令诚实回答,而不是拿着空上下文瞎编。
6.5 进阶能力一览(按需选用)
RetrievalAugmentationAdvisor 还预留了检索前后的扩展点,本文不展开代码,列出方向供进阶:
| 扩展点 | 作用 | 典型组件(以实际版本为准) |
|---|---|---|
| query 改写 / 压缩 | 检索前把口语问题改写成更适合检索的形式 | `RewriteQueryTransformer`、`CompressionQueryTransformer` |
| query 扩展 | 一个问题扩展成多个问题,多路召回合并 | `MultiQueryExpander` |
| 文档后处理 | 召回后去重、截断、过滤 | `DocumentJoiner`、各类 `DocumentPostProcessor` |
这些都属于「检索增强的高级玩法」,先把
documentRetriever这一路跑通,再按需往上叠。别一上来就全开——每加一个组件都多一层出错面。
第五部分 · 完整落地
第 7 章 完整示例:知识库问答
把第 2~6 章串成一个可跑的「检索召回 + 生成」服务。环境:JDK 21+、PostgreSQL(pgvector)、百炼 API Key(Embedding + Chat 两把能力)。
7.1 依赖(pom.xml)
在「向量化与索引」文档的依赖基础上,无需新增——spring-ai-alibaba-starter-dashscope 同时提供 Embedding 和 Chat 两种模型,ChatClient 由 Spring AI 自动装配:
<span><<span>properties</span>></span>
<span><<span>maven.compiler.release</span>></span>21<span></<span>maven.compiler.release</span>></span>
<span><<span>spring-boot.version</span>></span>3.5.9<span></<span>spring-boot.version</span>></span>
<span><<span>spring-ai.version</span>></span>1.1.2<span></<span>spring-ai.version</span>></span>
<span><<span>spring-ai-alibaba.version</span>></span>1.1.2.2<span></<span>spring-ai-alibaba.version</span>></span>
<span></<span>properties</span>></span>
<span><<span>dependencyManagement</span>></span>
<span><<span>dependencies</span>></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.boot<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-boot-dependencies<span></<span>artifactId</span>></span>
<span><<span>version</span>></span>${spring-boot.version}<span></<span>version</span>></span>
<span><<span>type</span>></span>pom<span></<span>type</span>></span><span><<span>scope</span>></span>import<span></<span>scope</span>></span>
<span></<span>dependency</span>></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-bom<span></<span>artifactId</span>></span>
<span><<span>version</span>></span>${spring-ai.version}<span></<span>version</span>></span>
<span><<span>type</span>></span>pom<span></<span>type</span>></span><span><<span>scope</span>></span>import<span></<span>scope</span>></span>
<span></<span>dependency</span>></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>com.alibaba.cloud.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-alibaba-bom<span></<span>artifactId</span>></span>
<span><<span>version</span>></span>${spring-ai-alibaba.version}<span></<span>version</span>></span>
<span><<span>type</span>></span>pom<span></<span>type</span>></span><span><<span>scope</span>></span>import<span></<span>scope</span>></span>
<span></<span>dependency</span>></span>
<span></<span>dependencies</span>></span>
<span></<span>dependencyManagement</span>></span>
<span><<span>dependencies</span>></span>
<span><!-- Web --></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.boot<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-boot-starter-web<span></<span>artifactId</span>></span>
<span></<span>dependency</span>></span>
<span><!-- Embedding + Chat 两种模型能力(DashScopeChatModel / DashScopeEmbeddingModel) --></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>com.alibaba.cloud.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-alibaba-starter-dashscope<span></<span>artifactId</span>></span>
<span></<span>dependency</span>></span>
<span><!-- 向量库:pgvector --></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-starter-vector-store-pgvector<span></<span>artifactId</span>></span>
<span></<span>dependency</span>></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.springframework.boot<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-boot-starter-jdbc<span></<span>artifactId</span>></span>
<span></<span>dependency</span>></span>
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>org.postgresql<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>postgresql<span></<span>artifactId</span>></span>
<span><<span>scope</span>></span>runtime<span></<span>scope</span>></span>
<span></<span>dependency</span>></span>
<span></<span>dependencies</span>></span>
7.2 配置(application.yml)
<span>spring:</span>
<span>ai:</span>
<span>dashscope:</span>
<span>api-key:</span> <span>${DASHSCOPE_API_KEY}</span>
<span>embedding:</span>
<span>options:</span>
<span>model:</span> <span>text-embedding-v3</span>
<span>dimensions:</span> <span>1024</span> <span># 与 pgvector dimensions 一致</span>
<span>chat:</span>
<span>options:</span>
<span>model:</span> <span>qwen-plus</span> <span># 生成模型</span>
<span>vectorstore:</span>
<span>pgvector:</span>
<span>initialize-schema:</span> <span>true</span>
<span>remove-existing-vector-store-table:</span> <span>false</span>
<span>dimensions:</span> <span>1024</span> <span># 与 embedding dimensions 一致</span>
<span>index-type:</span> <span>HNSW</span>
<span>distance-type:</span> <span>COSINE_DISTANCE</span>
<span>datasource:</span>
<span>url:</span> <span>jdbc:postgresql://localhost:5432/spring_ai</span>
<span>username:</span> <span>postgres</span>
<span>password:</span> <span>postgres</span>
新增的只有
chat.options.model——Chat 模型的配置,和前置文档的 Embedding 配置并列在spring.ai.dashscope下。
7.3 RAG 装配(RagConfig)
第 6.2 节的 RagConfig 原样搬过来即可:documentRetriever → ragAdvisor(可加 ContextualQueryAugmenter 兜底,见 6.4)。
7.4 问答 Controller
<span>package</span> com.example.rag;
<span>import</span> org.springframework.ai.chat.client.ChatClient;
<span>import</span> org.springframework.web.bind.annotation.GetMapping;
<span>import</span> org.springframework.web.bind.annotation.RequestParam;
<span>import</span> org.springframework.web.bind.annotation.RestController;
<span>/** 知识库问答入口:GET /ask?q=这东西咋退啊 */</span>
<span>@RestController</span>
<span>public</span> <span>class</span> <span>QaController</span> {
<span>private</span> <span>final</span> ChatClient chatClient;
<span>public</span> <span>QaController</span><span>(ChatClient chatClient)</span> {
<span>this</span>.chatClient = chatClient;
}
<span>@GetMapping("/ask")</span>
<span>public</span> String <span>ask</span><span>(<span>@RequestParam("q")</span> String question)</span> {
<span>// 一行搞定:召回 → 注入上下文 → 生成</span>
<span>return</span> chatClient.prompt()
.user(question)
.call()
.content();
}
}
前提:库里有知识。可以先调一个入库接口(复用前置文档的
IngestService)把「退货政策」写进去,再访问/ask?q=这东西咋退啊,验证端到端链路。
7.5 两种召回写法并存
- 只要召回结果自己处理(比如还要接重排、做溯源展示)→ 直接用
VectorStore.similaritySearch(SearchRequest)(第 2~4 章); - 要「召回 → 生成」一条龙 → 用
RetrievalAugmentationAdvisor+ChatClient(第 6 章)。
两者不冲突,甚至可以并存:VectorStore 负责「可控的召回」,Advisor 负责「自动化的问答」。
第 8 章 高频坑速查表
| 症状 | 环节 | 根因 | 解决 |
|---|---|---|---|
| 召回为空 / 全是无关内容 | 召回 | 检索与入库的 Embedding 模型不一致 | 入库、检索用**同一个模型**(同维度) |
| 召回不到该有的知识 | 召回 | `similarityThreshold` 设太高 | 下调阈值,或先不设(默认 0.0)看分数分布再定 |
| 召回到但答案被噪声干扰 | 召回 | `topK` 设太大 | 减小 topK(先 4 起步) |
| 过滤「不生效」 | 过滤 | 过滤 key 与入库 metadata key 不一致 | 对齐两处 key(大小写、命名完全一致) |
| 过滤「不生效」 | 过滤 | 值类型不一致(数字 vs 字符串) | 对齐值类型 |
| 召回为空时模型瞎编 | 生成 | 没做空上下文兜底 | 加 `ContextualQueryAugmenter.allowEmptyContext(true)` |
| 拿分数写死判断老出错 | 召回 | 把相似度分数当绝对阈值用 | 分数只用于**排序**,别当硬门槛 |
| 精确词(SKU/型号)召回差 | 召回 | 只用向量,缺关键词路 | 上混合检索(ES:BM25 + 向量 + RRF) |
| 换向量库后代码报错 | 召回 | 依赖了具体实现类而非接口 | 面向 `VectorStore` / `DocumentRetriever` 接口编程 |
附:本文与前置文档的关系
文档加载 → 文本清洗 → 文本分片 → 向量化 → 构建索引 → ★检索召回(本文) → 重排 → 生成
└──────── 前置文档(向量化与索引) ──────────┘ │
▼
检索三旋钮:topK / 相似度阈值 / 元数据过滤
可选项:混合检索(BM25 + 向量 + RRF)
收口:RetrievalAugmentationAdvisor + ChatClient
下一篇(重排)承接本文:召回粗筛出 topK 条后,用更精细的排序模型重新排序,挑出最相关的 2~3 条再交给生成。
干货密度高,三把旋钮、过滤DSL与RRF手算示例都配有代码和预期输出,适合正在用Spring AI落地RAG的工程师对照调参。