Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RAG 混合检索服务

BM25 关键词检索向量语义检索 融合起来,加上重排、熔断、缓存、降级, 再包一层 HTTP 接口 —— 一个可以交付的后端检索服务。

技术栈:Java 21 · Spring Boot 3.5 · MySQL 8.0 · Redis · 硅基流动免费模型 API (检索算法部分零第三方依赖,见第 13 节) 规模:29 个类 / 3428 行 · 数据 48 篇知识库 → 53 个检索块、180 条评测集 核心链路BM25 110 + EmbeddingService 163 + HybridRetriever 237 + Reranker 126 = 636 行

三个关键数字(均为本机实测,出处见第 8 节):

指标
消融实验最好的配置 HitRate@5 99.4%(MRR 0.959)
100 并发、缓存命中路径 P95 13.4 ms(无缓存对照 538.0 ms,降 97.5%)
Redis 检索结果缓存命中率 76.8%(冷启动口径,稳态 100%)

1. 这东西解决什么问题

电商客服场景,用户的问法千奇百怪:

问法 类型 谁能抓到
退款要几天到账 字面可匹配 关键词、向量都能
怎么退钱 同义表达,字面没有「退款」 只有向量能
ERR_401 是什么错误 精确串(错误码、型号、订单号) 只有关键词能

任何单一通道都会在某一类上失手。所以生产环境的 RAG 几乎都是混合检索 —— 这就是混合检索存在的理由,也是这个项目的出发点。


2. 30 秒跑起来

前提:JDK 21。 服务模式还要:MySQL 8(3306)+ Redis(6379)。 两个都没起也不会挂 —— 会自动降级成「内存切片 + 进程内缓存」,/health 里能看出来。

双击 干什么
运行-服务SpringBoot.bat 起 Spring Boot 服务 → http://localhost:8080
运行-演示.bat 跑 3 条典型查询,把两路召回的差异打出来(零依赖模式,不用数据库)
运行-评测.bat 跑 180 条评测集,出四组消融数据
运行-Redis.bat 起 Redis(本机不会开机自启,跑服务前先双击它
运行.bat 菜单:编译 / 演示 / 评测 / 起服务,四个入口都在里面
运行-服务.bat 第一版手写路由的服务(JDK 自带 httpserver),留作演进对照
运行-分词.bat / 运行-切片.bat / 运行-BM25.bat / 运行-向量.bat 单模块自检:把某个模块单独拎出来跑,打印真实打分过程

API Key 配在 key.txt(第一行直接写 Key,# 开头是注释)。 数据库密码配在 application-local.properties已 gitignore,不进仓库)。 没配也能跑 —— 会逐级降级,服务照样可用。

命令行等价写法:

java -cp out rag.Main demo          # 演示
java -cp out rag.Main eval          # 评测 + 消融
java -cp out rag.Main serve         # 起服务
java -cp out rag.Main ask 退款要几天 # 单次问答
java -cp out rag.Main search 怎么退钱 # 单次检索(带两路对比)

两个版本的对照(为什么保留两套): server/ApiServer.java 是第一版手写 HTTP 路由,spring/RagController.java 是 Spring MVC 版 —— 接口契约完全一样,只换了实现方式。核心算法(BM25 / 余弦 / 分词 / 切片 / 融合 / 熔断)两版共用、一个字节没改。 这就是"算法自研、胶水用现成"这条取舍的实物证据。


3. 目录结构

rag-mini/
├── pom.xml                 # Maven:Spring Boot 3.5 + JDBC + Redis
├── README.md               # 本文件
├── key.txt                 # API Key(已在 .gitignore 里,不会进仓库)
├── application-local.properties  # 数据库账号密码(同样 gitignore)
├── 运行-服务SpringBoot.bat  # Spring Boot 服务入口
├── 运行-Redis.bat          # 起 Redis(本机不自启)
├── 运行.bat                # 菜单:编译 / 演示 / 评测 / 起服务
├── 运行-演示.bat            # 演示:3 条典型查询的两路对比
├── 运行-评测.bat            # 评测 + 四组消融
├── 运行-服务.bat            # 第一版手写路由的服务(演进对照)
├── 运行-分词.bat            # 以下为单模块自检脚本
├── 运行-切片.bat
├── 运行-BM25.bat
├── 运行-向量.bat
├── 运行-向量逐行.bat
├── 运行-名词解释.bat
├── sql/
│   ├── schema.sql          # 建库建表 DDL(utf8mb4 / 索引顺序的理由)
│   └── EXPLAIN验证.md       # 索引命中的实测证据
├── redis-验证留档.md        # Redis 缓存 + 限流的实测证据
├── 压测-验证留档.md         # 100 并发 P95 + 命中率的实测证据
├── 熔断与降级-验证留档.md   # 熔断触发的实测证据
├── bench/
│   ├── bench_search.py     # 压测脚本(100 并发,三阶段对照,可复现)
│   ├── bench_result_2026-09-20.json   # 那次运行的原始结果
│   └── 原始输出-2026-09-20.txt         # 那次运行的完整日志
├── data/
│   ├── corpus.txt          # 48 篇电商客服知识库(含大量相邻混淆主题)
│   ├── qa.txt              # 180 条评测集(问题|期望文档|类型)
│   └── 评测结果.txt         # 消融实验的原始输出快照
└── src/rag/
    ├── RagMiniApplication.java        # Spring Boot 启动入口
    ├── Main.java                      # 命令行入口,5 种跑法(零依赖模式)
    ├── Config.java                    # 所有"魔法数字"集中在这里
    ├── net/Http.java                  # 零依赖 HTTP + JSON 工具
    ├── model/                         # Chunk / Hit / Answer / RetrieveResult
    ├── index/
    │   ├── Tokenizer.java             # 英文按词、中文 2-gram
    │   ├── TextSplitter.java          # 按标题切文档、按句末标点切块+重叠
    │   ├── BM25.java                  # 关键词检索核心
    │   ├── EmbeddingService.java      # 真 embedding + 缓存 + 熔断
    │   ├── Reranker.java              # cross-encoder 重排
    │   └── HybridRetriever.java       # 双路召回 + 归一化 + 融合
    ├── store/
    │   ├── ChunkRepository.java       # MySQL 持久化(JdbcTemplate + 批量 upsert)
    │   └── SearchCache.java           # 检索结果缓存(Redis + 降级 + 命中率计数)
    ├── web/
    │   ├── RateLimitInterceptor.java  # 固定窗口限流(INCR + 首次 EXPIRE)
    │   └── WebConfig.java             # 拦截器注册(Interceptor vs Filter 的取舍)
    ├── spring/
    │   ├── SpringConfig.java          # @Bean 装配
    │   ├── RagController.java         # 6 个 HTTP 接口
    │   ├── AskRequest.java            # record DTO
    │   └── GlobalExceptionHandler.java # @RestControllerAdvice 统一错误响应
    ├── tool/
    │   ├── CircuitBreaker.java        # 熔断器状态机
    │   ├── TtlCache.java              # 带 TTL 的 LRU 缓存(也是 Redis 的降级替身)
    │   └── TimeoutGuard.java          # 超时保护
    ├── service/
    │   ├── Generator.java             # 生成(prompt 约束 + 降级)
    │   └── KnowledgeService.java      # 编排层:切片→落库→读回→建索引
    ├── server/ApiServer.java          # 第一版手写路由(演进对照)
    └── eval/Evaluator.java            # 消融实验

4. 检索链路

                             ┌─→ BM25 关键词  ──→ Top-8 ──→ 归一化 ──→ 截断 ─┐
  用户问题 ──┬─ 分词(2-gram) ─┤                                              │
             │                └─→ bge-m3 向量 ──→ Top-8 ──→ 归一化 ──→ 截断 ─┤
             │                                                                │
             └─ 缓存命中?──→ 直接返回                                        ▼
                                                            加权融合 0.45 / 0.55
                                                                             │
                                                                             ▼
                                                        bge-reranker-v2-m3 重排
                                                                             │
                                                                             ▼
                                            Top-5  ──→  LLM 生成(Qwen2.5-7B)
                                                          │
                                                          └─ 失败 → 降级:直接返回原文片段

每一层都是可独立替换的:BM25 换成 ES、向量换成 pgvector、重排关掉、生成换成别的模型, 都不影响其他部分。这是分层做的意义。


5. 三个关键设计

5.1 为什么归一化要「除以本通道最大值」,而不是 Min-Max?

BM25 的分数无上界(可以 12.7,也可以 3.1),余弦在 [-1, 1]。不在同一量纲上直接相加, 等于让 BM25 单方面决定排序,向量通道形同虚设。

那为什么不用更"标准"的 Min-Max 归一化?因为它会平移: 把每个通道的末名强行压成 0、首名抬成 1。这会制造出一个并不存在的"零分文档"和"满分文档", 把通道内的相对差距人为放大。

「除以最大值」只做缩放、不做平移,保留原本的相对差距。代码在 HybridRetriever.normalize()

5.2 低相关证据过滤:两道闸

第一道:融合前截断。 Top-K 无条件取满 K 条的话,一条完全不相关的文档(分数很低但排第 8)照样会被塞进融合池, 稀释掉真正相关的那些。HybridRetriever.truncate() 保留「不低于本通道最高分 50%」的候选。

第二道:重排后过滤。 reranker 被要求返回 top_n 条,它就会凑够 top_n 条 —— 哪怕最后一条 relevance 只有 0.2。 而检索层的产出是要喂给大模型的上下文:混进一条不相关的片段, 轻则稀释注意力,重则把模型带偏、编一个基于错误资料的回答。 HybridRetriever.filterByRelevance() 再滤一遍,同时保底 3 条,防止上下文过薄导致回答质量反而变差。

两道闸都用「相对最高分」而不是绝对阈值 —— 不同 reranker 的分数尺度不一样 (有的 01,有的 -1010),卡绝对值的配置换个模型就失效。

实测:查「怎么退钱」时,原先 Top-5 里混进了「海外直邮说明」(relevance 0.21), 加上第二道闸后它被丢掉,上下文只剩真正相关的 4 条。

5.3 召回了为什么还要重排?

embedding(双塔) reranker(单塔)
编码方式 query 和文档各自编码 (query, 文档) 拼一起同时编码
能否看到词级交互 不能 能(cross-attention)
速度 快,文档向量可离线预计算 慢,每个候选都要跑一次前向
定位 广度召回 精度排序

所以工业界标准做法是两段式:双塔召回一批(便宜、广)→ 单塔重排这批(贵、准)。 这也解释了它为什么叫 re-rank 而不是 rank。

实测里重排的增益主要体现在 MRR(把对的往前挪),而不是 Hit Rate(捞回原本没召回的)。


6. HTTP 接口

服务起来后(运行-服务SpringBoot.bat),6 个接口:

方法 路径 干什么 为什么要有它
GET /health 服务状态 + 熔断器状态 + 数据源/缓存后端 可靠性:能被监控探针探到,降级与否一眼可见
GET /search?q=&k= 只检索不生成 检索层可独立交付 —— 检索质量能脱离大模型单独衡量
GET /debug/retrieve?q= 返回两路召回的中间态 可解释性:回答「为什么这条排第一」
GET /debug/doc?title= 某篇文档的全部检索块 MySQL 接进链路的证据接口(走 uk_title_chunk 索引)
POST /ask 检索 + 生成 完整 RAG(带固定窗口限流)
GET /stats 缓存命中率等运行指标 可观测性:命中率、模型调用数、失败数
curl "http://localhost:8080/search?q=怎么退钱"
curl "http://localhost:8080/debug/retrieve?q=退款要几天"
curl "http://localhost:8080/debug/doc?title=退款到账时间"
curl -X POST http://localhost:8080/ask \
     -H "Content-Type: application/json" \
     -d '{"question":"退款一般几天到账"}'
curl "http://localhost:8080/stats"
curl "http://localhost:8080/health"

/search/debug/retrieve 是关键设计:把检索和生成拆开交付。 大模型随时可能挂、可能换、可能涨价,但检索层是稳定的。 把检索和生成拆开交付,是这个设计最主要的价值。

/debug/doc 那条链路值得单独讲:接口 → KnowledgeService.chunksOfDoc()ChunkRepository.findByTitle()WHERE title = ? ORDER BY chunk_index → 命中 uk_title_chunk。 这条链路可以直接用 EXPLAIN 验证索引是否命中,见 sql/EXPLAIN验证.md


7. 消融实验

设置:语料 48 篇 → 53 个检索块;评测集 180 条;Top-5 内命中期望文档即算命中。 复现:双击 运行-评测.bat,约 1 分钟。

配置 HitRate@5 MRR exact synonym confusable
A. 纯 BM25(只用关键词) 0.894 0.797 0.963 0.688 0.692
B. 纯向量(只用语义) 0.961 0.876 0.978 0.906 0.923
C. 加权融合 0.45 / 0.55 0.978 0.907 0.985 0.938 1.000
D. 融合 + 重排(完整链路) 0.994 0.959 1.000 0.969 1.000

这张表在说什么

① 关键词的能力边界,在 synonym 那一列 BM25 在 exact 上 0.963,到 synonym 直接掉到 0.688 —— 差 27 个百分点。 它不认"同义表达",这是算法本身决定的,调参救不回来。

② 语义检索补的就是这个洞 向量把 synonym 从 0.688 拉到 0.906(+21.8 点), 但它在 exact 上只比 BM25 高 1.5 点。两者不是替代关系,是互补关系 —— 这句话就是整个项目的立项理由。

③ 融合优于任何单路 HitRate 0.978 比单路最好的向量(0.961)还高 1.7 点。 confusable 从 0.923 直接打到 1.000 —— 相邻文档互相干扰时, 两路投票比单路判断可靠得多。 (反过来说:如果融合后指标反而下降,说明权重或归一化方式有问题,这是个很好的调试入口。)

④ 重排的主要收益在"排序质量"上 MRR 从 0.907 → 0.959(+5.2 点),HitRate 只涨 1.6 点。 完全符合预期:rerank 只对已召回的候选重排序,召不回来的它一点办法都没有。 这说明召回和排序是两个独立的问题,必须分开优化。

⑤ "过滤"这件事比"排序"更被低估 D 组在 C 组基础上还多做了一件事:把 relevance 低于最高分 25% 的候选丢掉(第 5.2 节的第二道闸)。 synonym 列因此从 0.938 涨到 0.969。 看起来反直觉 —— 删掉候选,命中率怎么会上升? 因为 Top-5 是有限的五个位置,一个低相关候选赖在里面,就会把一个更相关的挤出榜单。 过滤不只是防幻觉的防御动作,它本身就提升指标。

剩下的 1 条失败案例

送到门口时我不想要了该怎么办  →  期望「拒收与退回流程」,实际 Top1「签收验货与破损处理」

「拒收与退回流程」「签收验货与破损处理」「上门取件服务」这三篇在语义上高度重叠, 而问题又是纯口语表达("不想要了""送到门口"),目标文档的核心术语一个都没出现。 这类问题的正解是 query 改写(先让模型把口语问题规整成检索式再检索), 而不是继续调权重 —— 知道边界在哪、下一步从哪下手,比刷指标重要。

数据说明:reranker 是远程 API,同一问题两次调用可能有极细微的分数浮动, 因此 HitRate 存在 ±1 条(约 0.5%)的正常噪音。这是真实评测该有的样子, 不要期望小数第三位完全可复现。

权重是怎么定的

0.45 / 0.55 不是拍脑袋来的,是拿这张表扫出来的: 向量略高于 BM25,是因为评测集里 synonym 类占比不低(32/180,17.8%)。 Config.W_BM25 / W_VECTOR 后重跑 运行-评测.bat 就能自己验证 —— 这是个可以当场演示的动作,比说"我调过参"有力得多。 上线时这个权重应该按线上流量分布重新扫,而不是用离线评测集的值。


8. 存储与性能实证(MySQL 索引 / Redis 缓存 / 100 并发压测)

第 7 节证明的是检索质量,这一节证明的是工程指标。两节的数字都能复现。 完整原始数据、对照组设计:sql/EXPLAIN验证.mdredis-验证留档.md压测-验证留档.md熔断与降级-验证留档.md

8.1 MySQL:切片落库 + 联合索引

启动流程:读语料 → 切片 → upsert 落库 → 从库读回 → 建 BM25+向量索引

/health  "datasource":"mysql(53 rows)"
/db      chunk 表 53 行(48 篇文档 → 53 块),中文无乱码,InnoDB / utf8mb4
入库      INSERT ... AS new ON DUPLICATE KEY UPDATE content = new.content   ← 幂等,重启不重复

EXPLAIN 三连(这是最值钱的一段):

SQL type key rows Extra
WHERE title = ? ORDER BY chunk_index ref uk_title_chunk 2 NULL(无 filesort)
WHERE title = ? AND chunk_index = ? const uk_title_chunk 1 NULL
WHERE chunk_index = ?违背最左前缀 ALL NULL 53 Using where

→ 第三行是「最左前缀」最有说服力的证据:索引压根没被优化器考虑(连 possible_keys 都是 NULL)。 → 第一行 Extra: NULL 说明索引顺带把 ORDER BY 的排序也免掉了。 → key_len = 514 = 128 × 4 + 2(utf8mb4 每字符 4 字节 + 变长长度前缀)。

为什么是 UNIQUE 而不是普通索引:① 数据完整性(同一篇文档的块序号不能重复); ② 只有唯一索引才能用 ON DUPLICATE KEY UPDATE 做幂等入库。

数据库挂了会降级:捕获异常 → 打一行警告 → 用内存切片建索引,/health 显示 memory(降级:...)MySQL 挂了服务照样能用。

密码不进仓库application.properties(提交)里用 ${DB_PASSWORD:} 占位 + spring.config.import=optional:file:./application-local.properties,真实密码在 application-local.properties(已 gitignore)。key.txt 同理。

8.2 Redis:检索结果缓存

两层缓存,分工不同(容易混淆,需要分清):

缓存什么 放哪 为什么
向量(文本 → 1024 个数) 进程内EmbeddingService 里的 TtlCache 大对象;一个进程算过就够了,不需要跨实例共享
检索结果(问题 → 完整 RetrieveResult RedisSearchCache 跨实例共享;命中时 BM25、向量、融合、重排全部免掉,这一层才算得出命中率
命中率的分母 = hits ÷ (hits + misses),两个计数器都在 Redis 里(跨实例合并,重启不归零*)
缓存键直接是问题原文(可读:redis-cli KEYS "rag:search:*" 一眼看出缓存了什么)
TTL 300 秒;size() 用 SCAN 不用 KEYS(KEYS 会阻塞 Redis 单线程)

为什么只给 /ask 限流(固定窗口 30 次/分钟):那是唯一真调大模型的入口,最贵。 INCR + 首次EXPIRE。实测第 31 次请求返回 429 {"error":"请求过于频繁","retryAfterSeconds":"11"}

两个已知边界: ① 固定窗口的临界问题 —— 60 秒边界处实际能过 2 倍流量,更严谨的做法是滑动窗口 / 令牌桶; ② 缓存雪崩 —— TTL 固定 300s 会同时过期,加随机抖动可以摊平。

* Redis 没开持久化,重启计数器会归零。想留住统计要开 AOF/RDB,或者把指标打给 Prometheus。

8.3 100 并发压测(P95)

压测对象是 /search,不是 /ask/ask 的延迟被生成模型排队主导(实测有 30s 超时), 压出来的是模型厂商的数字。测什么就得让工具只测那个东西。

对照组是干净的:同一个 JVM、同一份索引、同一批 50 个问题(固定随机种子), 唯一变量就是「检索结果缓存」 —— S3 每轮前 DEL rag:search:*

阶段 做法 P50 P90 P95 P99 命中率
S1 冷启动 缓存从空开始,100 并发 × 5 轮 = 500 请求 18.3 4433.3 4666.8 4710.3 76.8%
S2 稳态 池子已热,100 并发 × 5 轮 = 500 请求 9.4 12.5 13.4 24.7 100%
S3 对照 无缓存,每轮前清空,100 并发 × 3 轮 = 300 请求 463.2 535.0 538.0 542.9 0%
缓存让 P95 从 538.0ms 降到 13.4ms(降 97.5%),P50 从 463.2ms 降到 9.4ms(降 98.0%)
吞吐:无缓存 165 QPS → 缓存命中约 2400 QPS(单实例,本机)
全程非 200 响应 0 个,rerankFailures=0,三个熔断器始终 CLOSED → 没有降级把延迟测低

对照组成立的三条证据:S3 的 misses 增量正好 300、hits 增量 0(缓存真清干净了); rerankFailures=0(没有"重排失败退融合序"把延迟测低);非 200 响应 0(没有超时混进统计)。

S1 第 1 轮特别值得看:100 个请求同时打 50 个冷问题 → P50 4434ms、QPS 只有 9。 这是一次真实的"缓存击穿"现场,也解释了为什么缓存值得做。

13.4ms 里都干了什么:Redis GET(1 次往返)+ 命中计数 INCR(1 次往返)

  • Jackson 反序列化 + 序列化响应。一次模型调用都没有 —— 这句是关键。

9. 可靠性四件套

机制 实现 解决什么
熔断 CircuitBreaker:CLOSED → 连续 5 次失败 → OPEN(冷却 60s) → HALF_OPEN 放一个试探 → 成功则 CLOSED 下游挂了时快速失败。不熔断的话每个请求都要等满 30s 超时,线程被打满,故障从"下游慢"升级成"自己也不可用"
超时 TimeoutGuard:CompletableFuture + get(timeout) 双保险。HTTP 客户端自己有超时,这层保护的是"任意我们自己写的逻辑"(比如将来换成慢 SQL)
缓存 进程内 TtlCache:LinkedHashMap(accessOrder) LRU + TTL 5 分钟;跨实例的检索结果缓存换成 Redis(SearchCache,TTL 300s) 客服场景问题高度重复(实测命中率 76.8%,稳态 100%),命中时一次模型调用都不发
降级 embedding 挂了 → 退化为纯 BM25;生成挂了 → 直接返回原文片段并标记 generated=false 功能打折,但服务可用。用户宁可看到资料原文,也不愿看到 500

三层降级链(从下往上逐级兜底):

embedding 熔断/超时 → 跳过向量通道,只走 BM25        → vectorSkipped=true
rerank   熔断/超时 → 退回融合结果的原始顺序           → 静默降级,无感知
chat     熔断/超时 → 返回 Top 片段原文 + degradedReason → generated=false

关于超时的边界Future.cancel(true) 只是给线程打中断标记, 如果任务本身不检查中断,它还会在后台跑完。所以超时的语义是"让调用方尽快拿到失败并走降级", 不是"把任务真的杀掉"。要做到真正可中断,必须让任务内部配合。


10. 常见问题

Q:为什么中文用 2-gram 而不是正经分词? 正经分词要挂词典(jieba 之类),引入依赖。2-gram 是零依赖兜底: "退款要几天" → [退款, 款要, 要几, 几天],只要文档里有"退款"就能命中。 代价是产出"款要"这种无意义组合,属于拿一点噪音换召回率。 如果语料里专有名词多(型号、错误码),2-gram 会把它们切碎,那时要么上词典分词,要么把专有名词做成保护词整词入索引。

Q:为什么切片要重叠? 切点两边往往同属一个语义("退款一般在 3 至 5 个 | 工作日到达原支付账户"), 不重叠的话两边都拿不到完整信息,回答会缺一半。这里是 220 字一块、40 字重叠。

Q:BM25 的三个参数什么含义? IDF 词越罕见权重越高;k1=1.5 控制词频饱和曲线(出现 1→2 次收益大,5→10 次几乎没收益); b=0.75 长度归一化强度(长文档天然容易命中词,必须惩罚,否则长文档永远霸榜)。

Q:为什么 BM25 今天还活着? 不需要训练、不吃 GPU、可解释(能明确说命中了哪些词), 而且对专有名词、错误码这种精确串的召回能力,向量模型至今打不过它。

Q:RAG 和 Agent 什么关系? 并列路线,不是递进关系。RAG = 检索 + 生成,解决"知识不在模型里"的问题; Agent = LLM + 工具 + 循环,解决"要分多步、要调外部系统"的问题。 RAG 在 Agent 架构里可以扮演"一个被调用的工具",但两者不是同一个东西。

Q:这个项目最难的地方是什么? 不要答"调 API"。答融合:两条分数分布完全不同的通道怎么合成一个可信的排序。 具体就是归一化方式的选择、截断阈值的定法、权重怎么调 —— 而且这些事情都有数据支撑(消融实验)。

Q:怎么保证模型不胡说? prompt 里写死"只用【资料】里的信息,没有就说没有"。 这是 RAG 里唯一能压住幻觉的地方。另外生成失败时降级返回原文,而不是让模型自由发挥。


11. 六段核心代码

下面是整个检索链路里最关键的六段实现。

1. BM25 打分(BM25.search() 内层循环)

double idf = Math.log(1.0 + (n - df + 0.5) / (df + 0.5));
double denom = f + k1 * (1 - b + b * len / avgLen);
scores[i] += idf * (f * (k1 + 1)) / denom;

2. 归一化(HybridRetriever.normalize()

double max = 0;
for (Hit h : hits) max = Math.max(max, h.score);
for (Hit h : hits) out.add(h.withScore(h.score / max, h.channel));

3. 加权融合(HybridRetriever.fuse()

for (Hit h : keywordHits) scoreMap.merge(h.chunk.id(), h.score * W_BM25, Double::sum);
for (Hit h : vectorHits)  scoreMap.merge(h.chunk.id(), h.score * W_VECTOR, Double::sum);

4. 熔断状态机(CircuitBreaker.allowRequest() + onFailure()

// OPEN 且冷却结束 → 转 HALF_OPEN,放一个试探
if (state == State.OPEN && now - openedAt >= openMillis) { state = HALF_OPEN; return true; }
// 连续失败到阈值,或 HALF_OPEN 试探失败 → 跳闸
if (state == State.HALF_OPEN || ++consecutiveFailures >= failThreshold) { state = OPEN; openedAt = now; }

5. 余弦相似度(EmbeddingService.cosine()

double dot = 0, na = 0, nb = 0;
for (int i = 0; i < n; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
return dot / (Math.sqrt(na) * Math.sqrt(nb));

6. 固定窗口限流(RateLimitInterceptor

// 窗口号 = 当前毫秒 / 60000,窗口号一变就等于"自动开了新窗口"
long window = System.currentTimeMillis() / 60000L;
String key = "rag:rl:" + ip + ":" + window;
Long count = redis.opsForValue().increment(key);      // INCR 是原子的,天然并发安全
if (count != null && count == 1L) {                   // ★ 只在第一次进来时设过期
    redis.expire(key, Duration.ofMinutes(1));         //   每次都 EXPIRE 会把窗口无限延长
}
if (count != null && count > limitPerMinute) {
    // 返回 429 + retryAfterSeconds,让客户端知道什么时候能重试
}

这个实现的已知问题(临界问题):60 秒边界处能过 2 倍流量(前 30 次卡在窗口尾、后 30 次卡在窗口头)。 正解是滑动窗口(ZSet 存时间戳)或令牌桶。这里没做,是因为两条命令就能满足当前量级, 而滑动窗口要额外维护 ZSet、令牌桶要一套 Lua —— 属于「知道取舍在哪」的主动选择。


12. 从"能跑"到"能上":已经落地了什么、还剩什么

核心检索算法刻意只用 JDK 标准库(BM25 / 余弦 / 2-gram 分词 / 切片 / 融合 / 重排编排 / 熔断器), 为的是"双击就能跑、一行不用装"。但工程胶水不该自研 —— 所以:

✅ 已经落地的迁移(这些不再是"计划")

原来的样子 现在 换它解决了什么
data/corpus.txt 每次启动现场切 MySQL chunk + (title, chunk_index) 唯一联合索引 + 幂等 upsert 切片结果持久化;数据源从文件变成库;可按索引取某篇文档的块
TtlCache 存检索结果 Redis SearchCache(带进程内降级) 跨实例共享、能算出真实命中率(实测 P95 538ms → 13.4ms)
ApiServer 手写 HTTP 路由 + 手拼 JSON Spring Boot 3.5 + Tomcat + Jackson 参数校验、统一异常响应、线程池、生态(监控/链路追踪)
只有 /search 能独立压 bench/bench_search.py 三阶段对照压测 性能指标可复现、可对照

这个取舍的边界很清楚BM25 / 余弦 / Tokenizer / TextSplitter / HybridRetriever / Reranker 这些一个字节都没改,是项目的核心价值所在; 只把手写 HTTP + 手拼 JSON 换成 Spring MVC + Jackson(胶水层自研是坏味道)。

⬜ 还没做(按优先级排序)

现状 该迁移到 为什么
内存 BM25 Elasticsearch 分布式、倒排索引、分词器生态、原生 BM25
chunkVecs 内存数组 pgvector / Milvus 语料上万条后全量扫余弦(O(n))单次要几百毫秒,必须换 ANN(HNSW/IVF)
固定窗口限流 滑动窗口 / 令牌桶 消掉窗口边界的 2 倍流量问题
固定 300s TTL TTL + 随机抖动 防缓存雪崩
缓存空值 / 布隆过滤器 防缓存穿透(现在没做) 查不存在的问题每次都会穿透到检索层
同步调用 异步 + SSE 流式返回 生成慢,流式能显著降低首字延迟
指标只在 /stats Prometheus + Grafana /stats 是给自己看的,上线要进监控体系

判定标准是换它解决了什么问题,而不是用了什么新技术。


13. 已知的取舍(主动说出来比被问出来好)

  • 进程内缓存只值一层:向量缓存仍在进程内(大对象、不带跨实例需求), 检索结果缓存已经搬到 Redis;但限流和缓存的降级路径都还是本机兜底 —— Redis 一挂,多实例下就退回"各算一份"。
  • BM25 索引全内存:语料大了内存吃满。生产上用 ES。
  • 向量检索全量扫:O(n),适合演示和中小规模,上规模必须换 ANN。
  • 不引 JSON 库:手写解析器是为了零依赖,健壮性不如 Jackson。真实项目该引就引。 (SearchCache 里为了存 JSON 用了 Jackson,这里算一次"承认取舍":算法层零依赖,存储层用现成的。)
  • Redis 无持久化:缓存丢了就丢了(本来也是缓存);但命中率计数器也会归零,指标应外送。
  • 压测是单机单实例:没验证多实例下 Redis 的共享价值,也没测长时间运行后的 TTL 过期场景。
  • 评测集 180 条是自己构造的:够跑通流程,但不构成统计学意义。真实项目要用线上日志挖掘。
  • 语料只有 53 个块:无缓存路径 538ms 里有 500ms 是重排模型调用,不是本地计算 —— 块数上万后这个数一定会变。
  • 相关性阈值是相对的,遇到"全都是垃圾分"会失效(09-20 实测): 拿一个语料里完全没有的问题("今天天气怎么样")去问,BM25 命中 0 条、 向量那 8 条的余弦全在 0.0001 以下,但 filterByRelevance 用的是 max × 0.25 —— 阈值跟着最高分一起塌到 0.000025,于是一个都没滤掉,照样返回 5 条。 要解决得加一条绝对下限(比如 0.2 以下直接判为无相关内容)。
  • "该不该答"完全交给 prompt:上面那个跑题问题,检索层照样返回 5 条、 /ask 也照样 generated=true —— 只看接口是看不出跑题的。 真正拦住它的是 SYSTEM_PROMPT 第 2 条,模型答「根据现有资料无法回答这个问题。」 这是刻意的取舍:在检索层卡相关性容易误杀真问题,宁可让模型自己认输。

数据文件 data/corpus.txt 是自建的电商客服知识库,48 篇文档; data/qa.txt 是配套的 180 条评测集,按 exact / synonym / confusable 三类构造。 语料之所以做到 48 篇,是因为语料太小(比如 10 篇)时 Top-5 的召回面过大, 指标会虚高到没有区分度 —— 这一点在第 7 节有展开。

About

BM25 + 向量混合检索的 RAG 后端服务|重排 · 熔断 · 缓存 · 降级|P95 13.4ms

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages