RAG检索增强生成:召回

文章来源声明: 原文作者:学者猫头鹰; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684565470045306915; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

干货密度高,三把旋钮、过滤DSL与RRF手算示例都配有代码和预期输出,适合正在用Spring AI落地RAG的工程师对照调参。

> RAG 查询链路的**第一步**:用户提问后,从向量库(可能还有倒排索引)捞出最相关的 topK 条知识块,供后续「重排 → 生成」使用。 > > **版本**:Spring AI `1.1.2` · Spring AI Alibaba `1.1.2.2` · Spring Boot `3.5.9` · JDK `21+`**模型**:阿里云百炼 DashScope 通义千问(Embedding `text-embedding-v3` + Chat `qwen-plus`) **向量库**:PostgreSQL + pgvector(可选 Elasticsearch 做倒排/混合检索) **前置**:本文接在「向量化与索引」之后,入参是「已写入向量库的知识块」+「用户 query」。

导读

本文解决的问题

前置文档已经把知识块「向量化 + 建索引」写进了向量库。但写进去不等于能用——用户来一个问题,你怎么从库里把「最相关的几条」捞出来?

本文教你三件事,把「躺在库里的向量」变成「能答得上话的知识」:

  1. 语义召回(核心):把用户问题也向量化,和库里的向量算相似度,取最近的 topK 条;
  2. 召回调优:用 topKsimilarityThresholdfilterExpression 三把旋钮控制「召回多少、要多准、只召回哪些范围」;
  3. 接进生成:把召回结果自动塞进大模型上下文,完成「召回 → 生成」的闭环(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 个工作日……

⚠️ 分数只有相对意义,没有绝对意义。不同向量库、不同距离度量算出来的分数量纲不同(余弦是 01,欧氏距离是 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();

topKsimilarityThreshold 是叠加关系,不是二选一:先按阈值筛掉低分的,再从剩下的里取前 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 节在入库时写进了 sourcecategorychunk_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,可直接赋给 filterExpressiongroup() 用来给复杂条件加括号,避免 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 纯向量检索的盲区

向量检索擅长「语义相近」,但对两类东西抓不准:

  1. 专有名词、精确术语:SKU 编号 RT-2024-8890、型号 iPhone 15 Pro、人名、法律条文编号……这些词库里可能只出现一两次,向量模型对它们「没见过 / 记不牢」,相似度算出来经常不是最高;
  2. 字面精确匹配:用户就是想搜「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。注意,两路的分数只用来排「本路内部的序」,不参与融合。

① 向量路(语义)排名

排名文档内容摘要相似度
1doc\_B退款将在审核通过后 3~5 个工作日内原路退回0.81
2doc\_A7 天无理由退货…3~5 天退款0.76
3doc\_D签收后 7 天内可申请无理由退货…0.70

② 关键词路(BM25)排名

排名文档内容摘要BM25
1doc\_A7 天无理由退货…3~5 天退款9.2
2doc\_D签收后 7 天内可申请无理由退货…8.1
3doc\_C退货申请通过后,平台安排上门取件5.5

两路分数完全不可比(向量 0.700.81,BM25 5.59.2),且结果还不一样doc_B 语义最像但没出现「7 天退货」字眼,只有向量路捞到;doc_C 有「退货」字但语义弱,只有关键词路捞到。直接合并根本没法排。

③ 套 RRF 公式(k=60):

文档向量路 rank关键词路 rankRRF 计算RRF 总分
doc\_A211/(60+2) + 1/(60+1)**0.0325**
doc\_D321/(60+3) + 1/(60+2)**0.0320**
doc\_B1—(未进)1/(60+1) + 0**0.0164**
doc\_C—(未进)30 + 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_Bdoc_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(纯业务实现)

先记牢三个事实

  1. Spring AI Alibaba 1.1.2.2 的标准向量库,只封装了向量检索(外加 filterExpression 元数据过滤这个附带参数);
  2. 没有内置 BM25 正文关键词检索,也没有内置 RRF 融合——框架不会帮你自动「向量 KNN + BM25」两路一起跑;
  3. 所以混合检索要自己动手三步:① 调向量检索(框架自带)→ ② 调 ES 的 BM25(原生客户端)→ ③ 写 RRF 融合(纯 Java)

下面参考 DataAgent 的 ES 混合检索思想,剥离业务,只留核心三步。不依赖 DataAgent,纯业务实现,复用 Spring AI 原生 Document

前置:Elasticsearch 8.x;索引里每篇文档同时存 embeddingdense_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()(ES match 走正文倒排)→ ③ 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 原样搬过来即可:documentRetrieverragAdvisor(可加 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 条再交给生成。