springboot+langchain4j实战Day 16 — 混合检索 + Reranker 重排序

Day 16 --- 混合检索 + Reranker 重排序

一、先看效果

做完 Day 4 的纯向量检索之后,我发现一个问题:搜公司名字经常搜不到。比如知识库里明确写了"码哥科技",但 Embedding 模型从来没在训练数据里见过这个词,它只能瞎猜。

于是做了混合检索,效果对比如下:

搜什么 Day 4(纯向量) Day 16(混合+Rerank)
"码哥科技退款流程" Top-1 不相关 Top-1 精确命中
"API_KEY 配置在哪" 排名靠后 Top-1,0.997 分
"产品有哪些功能" 还行 更准、排序更合理

核心变化是:从"只靠一种方式找"变成"两种方式找 + 互相印证 + 裁判精挑"。下面拆开讲每一步。


二、为什么不只用向量检索

Day 4 用的是纯向量检索,流程很简单:

css 复制代码
问句 → BGE Embedding → 1024 维向量 → PGVector 余弦距离 → Top-20

问题出在 Embedding 这一步。拿"码哥科技退款流程"来说,模型把每个词都变成一组浮点数,但它根本没见过"码哥科技"这四个字。训练数据里没有你公司的名字,它只能拿语义相近的词去近似。近似对了算运气,近似错了是常态。

向量检索擅长"意思差不多"的匹配,不擅长"一模一样"的匹配。

所以我在向量检索旁边加了一条关键词路。关键词路不关心语义,只管字面:

arduino 复制代码
问句 → 去标点 → "码哥科技退款流程"
  → 2-gram: [码哥, 哥科, 科技, 技退, 退款, 款流, 流程]
  → 3-gram: [码哥科, 哥科技, 科技退, ...]
  → ILIKE '%退款%' → 找到所有含"退款"的文档

不管"退款"是什么意思,只要文档里出现了这两个字就算命中。公司名、API 名、编号、人名------这些向量模型搞不定的东西,字面匹配直接解决。

两条路各司其职:

  • 搜"产品功能" → 向量路好(语义泛化,"功能"太泛关键词不好使)
  • 搜"API_KEY" → 关键词路好(精确命中,向量没见过这个组合)
  • 搜"李四退款" → 两条路都起作用,交叉印证

三、合并两条路的结果:RRF

两路都返回 Top-20,但分数完全不在一个量纲上:

  • 向量路:0.6 ~ 0.9(余弦相似度)
  • 关键词路:0 ~ 8(命中了几条 N-gram)

直接加权?0.6 × 权重 + 3 × 权重?权重怎么试都不稳定。

RRF(Reciprocal Rank Fusion)的做法简单粗暴:不看分数绝对值,只看排名位置。

举个例子。搜"码哥科技的产品":

ini 复制代码
向量排名:              关键词排名:
  第1名:文档A             第1名:文档C
  第2名:文档B             第2名:文档A
  第3名:文档D             第3名:文档B

文档A = 1/(60+1) + 1/(60+2) = 0.0325  → 两路都靠前,第一
文档B = 1/(60+2) + 1/(60+3) = 0.0320  → 第二
文档C = 1/(60+21)+ 1/(60+1) = 0.0287  → 向量路没排进去,第三
文档D = 1/(60+3) + 1/(60+21)= 0.0282

公式就是 1/(k + 排名),两路累加。k=60,TREC 论文的经典常数,让分数曲线不会因为排名差异太大而跳跃。

这个思路的妙处:不管后面加几条路,都用排名位置对齐,不需要调权重。 后面 Day 18 加第三条路(ES BM25),RRF 公式一字不改就能用。


四、RRF 还不够,加 Reranker 精排

RRF 只是合并排名位置,完全不理解文档内容和问题的关系。排第一的可能只是"两条路都恰好没漏",不代表它真的相关。

这时候引入 Reranker。跟向量模型(Bi-Encoder)不同,Reranker 是 Cross-Encoder,把问题和文档成对喂进去,让模型"仔细读":

arduino 复制代码
输入:
  问题:"码哥科技的核心产品是什么"
  文档:"码哥科技成立于2023年,核心产品包括码哥AI中台..."

Reranker 内部:问题里的"核心产品"直接注意到文档里的"AI中台",双向交互打分

输出:0.9974 分

Bi-Encoder 把问题和文档分别编码,两人从未"见过面"。Cross-Encoder 让他们面对面交流,准确度自然高很多。

代价是速度。Bi-Encoder 可以提前把全库文档编码好,查的时候只算距离。Cross-Encoder 必须现场对每个(问题, 文档)对算一遍。所以只对 RRF 融合后的 Top-20 跑 Reranker(几百毫秒),不对全库跑(好几秒)。

调的是硅基流动的 /v1/rerank 接口,模型 BAAI/bge-reranker-v2-m3:

json 复制代码
POST https://api.siliconflow.cn/v1/rerank
{
  "model": "BAAI/bge-reranker-v2-m3",
  "query": "码哥科技的核心产品是什么",
  "documents": ["文档1", "文档2", ...],
  "top_n": 5
}

五、完整流水线

scss 复制代码
用户问句
  │
  ├─ 阶段1:双路召回
  │   ├─ 向量路:Embedding → PGVector → Top-20
  │   └─ 关键词路:N-gram → ILIKE → Top-20
  │
  ├─ 阶段2:RRF 融合
  │   score = 1/(60+向量排名) + 1/(60+关键词排名)
  │   → 去重 → 排序
  │
  └─ 阶段3:Reranker 精排
      POST /v1/rerank → relevance_score → Top-5

如果 Reranker API 挂了怎么办?做了降级:API 调用失败时不抛异常,直接返回 RRF 结果截断到 Top-5。效果差一点,但至少不报 500。

java 复制代码
try {
    // 调 Reranker API → 解析响应
} catch (Exception e) {
    log.error("[Rerank] 失败:{}", e.getMessage());
    return candidates.stream().limit(topN).collect(Collectors.toList());
}

六、中文关键词检索:不用分词器

做中文关键词检索,第一反应可能是接 jieba 或 HanLP 分词。但我不想引入额外依赖,就用了一个取巧的方案:滑动窗口 N-gram。

java 复制代码
// 输入:"码哥科技的核心产品是什么"
// cleanQuery() → 去标点、英文、数字 → "码哥科技核心产品"
// generateNgrams() → 2-4 字窗口,取前 8 个:
//   [码哥, 哥科, 科技, 技核, 核心, 心产, 产品, 码哥科]

每条 N-gram 变成一条 SQL CASE WHEN:

sql 复制代码
score = (CASE WHEN text ILIKE '%科技%' THEN 1 ELSE 0 END
       + CASE WHEN text ILIKE '%核心%' THEN 1 ELSE 0 END
       + CASE WHEN text ILIKE '%产品%' THEN 1 ELSE 0 END
       + ...) * (1.0 / (1 + length(text) / 500.0))

加了一个长度惩罚 1/(1 + len/500):50 字的片段命中 3 个词条,应该排在 5000 字的文章命中 3 个词条前面。短片段更可能是精确答案。

当然这个方案有局限性------它会产生"哥科""技核"这种无意义片段,但这些片段在知识库文档里几乎不会出现,命中的大概率是"科技""核心""产品"这些有实际语义的。后续 Day 18 加了 ES BM25 之后,关键词路就退居二线了。


七、项目结构

bash 复制代码
day16-hybrid-rag/
├── pom.xml                              # SB 3.4.3, LC4j 1.13.1, pgvector
├── docs/
│   ├── day16-reference.md               # 代码速查
│   └── day16-ai-concepts-teaching.md    # 概念拆解
└── src/main/
    ├── java/com/day16/demo/
    │   ├── Day16Application.java        # 启动入口,端口 8088
    │   ├── config/
    │   │   ├── ChatModelConfig.java      # LLM + Embedding Bean
    │   │   └── DataInitializer.java      # 启动时自动向量化
    │   ├── controller/
    │   │   └── SearchController.java     # /search + /rag/chat
    │   ├── core/
    │   │   └── HybridSearchResult.java   # 结果 DTO
    │   ├── dto/
    │   │   └── ApiResult.java            # 统一响应
    │   └── rag/
    │       ├── HybridSearchService.java  # 三阶段编排
    │       └── RerankService.java        # Reranker API 调用
    └── resources/
        ├── application.yml               # 端口 8088, 硅基流动, PGVector
        ├── schema.sql                    # PGVector 建表
        ├── docs/                         # 4 篇 .txt 知识库文档
        └── static/index.html             # 前端测试页面

知识库初始化是 DataInitializer 在启动时自动做的:检查表里有没有数据 → 没有就扫描 classpath:docs/ 下的 txt → 按段落切成 ≤300 字的片段 → 调 Embedding API 向量化 → 批量写 PGVector。幂等的,启动多少次都不会重复写。

API Key 用 Jasypt 加密存在 application.yml 里:

yaml 复制代码
siliconflow:
  api-key: ENC(K1krO1oc+4nbWKTGQ/ZUQCVYs/HWQaD...)

启动的时候设环境变量 JASYPT_PASSWORD 就行。


八、API

两个端点,都返回 {code, message, data} 格式:

GET /search --- 纯检索:

bash 复制代码
curl "http://localhost:8088/search?query=码哥科技的核心产品&table=day4_rag_store"

返回的每条结果里 source 字段标了来源:rerank 是 Reranker 精排后的,rrf 是融合但没重排的,vectorkeyword 是单路的。

GET /rag/chat --- RAG 对话:

bash 复制代码
curl "http://localhost:8088/rag/chat?message=码哥科技有多少员工&table=day4_rag_store"

返回检索到的文档 + 拼好的 RAG Prompt,可以直接发给 DeepSeek 拿到回答。


九、技术栈

组件 版本/型号 用途
Spring Boot 3.4.3 应用框架
Java 17 运行语言
LangChain4j 1.13.1 LLM/Embedding 调用
PGVector PostgreSQL 17 扩展 向量存储与相似度查询
Embedding BAAI/bge-large-zh-v1.5 文本向量化
Reranker BAAI/bge-reranker-v2-m3 精排
DeepSeek-V3 硅基流动托管 RAG 对话
Jasypt 3.0.5 API Key 加密

十、踩坑记录

这几条坑都是真实跑出来的,每条都浪费了我至少半小时:

1. 启动报 NoSuchBeanDefinitionException: DataSource

pom.xml 里只引了 postgresql 驱动,忘了引 spring-boot-starter-jdbc。Spring Boot 看到 pg 驱动就自动配 DataSource,但没有 jdbc starter 就报找不到 Bean。加上 spring-boot-starter-jdbc 解决。

2. Reranker 返回 200,反序列化报错

硅基流动的 rerank 接口返回的 JSON 里多了一个 DTO 没定义的字段,Jackson 反序列化直接炸了。DTO 上加 @JsonIgnoreProperties(ignoreUnknown = true) 解决。教训:对接第三方 API,DTO 永远要加这个注解。

3. 关键词检索始终返回 0 条

一开始我用正则 \s+ 切词,发现中文没有空格,整个句子被当成了一个词,什么都匹配不出来。改成滑动窗口 N-gram(2-4 字)就好了。

4. String.format 用了 Python 语法

日志打了 %s 出来,查了半天发现写了 {:.4f}------Python 的格式字符串,Java 里是 %.4f。两种语言混着写就容易出这种低级错误。

5. IDE 自动导入 javax.naming.directory.SearchResult

我写了个 DTO 叫 SearchResult,IDEA 自动导了个 JDK 自带的同名类。编译不报错但逻辑全错。后来改名 HybridSearchResult 解决。给类起名的时候最好先查一下 JDK 和框架里有没有同名的。

6. LangChain4j 的 dev.langchain4j.service.Result 被误导入

同上,自定义 Result 类跟框架重名了。改成 ApiResult 彻底解决。


十一、怎么跑起来

bash 复制代码
# 确认 PGVector 在跑
docker ps | grep ai-postgres

# 设密钥
export JASYPT_PASSWORD=day16-secret-key

# 编译启动
cd day16-hybrid-rag
mvn clean compile spring-boot:run -DskipTests

# 测一下
curl "http://localhost:8088/search?query=码哥科技&table=day4_rag_store"

# 浏览器打开前端
# http://localhost:8088

代码在 giteeday16-hybrid-rag 目录。配套速查手册和概念拆解在 docs/ 下面,需要的话直接看。

相关推荐
Ai拆代码的曹操1 小时前
揭秘"幽灵 CPU":top 抓不到的短命进程,才是真正的 CPU 杀手
后端
IT_陈寒1 小时前
Python里这个赋值坑,连老司机都能翻车
前端·人工智能·后端
唐青枫2 小时前
推荐一个 Zig Web 工程骨架:wing-app
后端
葫芦和十三12 小时前
图解 MongoDB 13|WiredTiger 存储引擎:B-tree、页和 checkpoint 三件套
后端·mongodb·agent
葫芦和十三12 小时前
图解 MongoDB 14|Cache 与淘汰:WiredTiger 的内存治理
后端·mongodb·面试
IT_陈寒15 小时前
Vue这个坑我跳了两次,原来问题出在这
前端·人工智能·后端
ServBay16 小时前
9 个 Python 第三方库推荐,不用 AI 都好像多出一个团队
后端·python
用户83562907805116 小时前
如何使用 Python 添加和管理 Excel 批注(完整示例)
后端·python
用户83562907805117 小时前
使用 Python 管理 Excel 工作表:创建、复制、删除与重命名
后端·python