把 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%) |
电商客服场景,用户的问法千奇百怪:
| 问法 | 类型 | 谁能抓到 |
|---|---|---|
| 退款要几天到账 | 字面可匹配 | 关键词、向量都能 |
| 怎么退钱 | 同义表达,字面没有「退款」 | 只有向量能 |
| ERR_401 是什么错误 | 精确串(错误码、型号、订单号) | 只有关键词能 |
任何单一通道都会在某一类上失手。所以生产环境的 RAG 几乎都是混合检索 —— 这就是混合检索存在的理由,也是这个项目的出发点。
前提: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 / 余弦 / 分词 / 切片 / 融合 / 熔断)两版共用、一个字节没改。
这就是"算法自研、胶水用现成"这条取舍的实物证据。
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 # 消融实验
┌─→ 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、重排关掉、生成换成别的模型, 都不影响其他部分。这是分层做的意义。
BM25 的分数无上界(可以 12.7,也可以 3.1),余弦在 [-1, 1]。不在同一量纲上直接相加, 等于让 BM25 单方面决定排序,向量通道形同虚设。
那为什么不用更"标准"的 Min-Max 归一化?因为它会平移: 把每个通道的末名强行压成 0、首名抬成 1。这会制造出一个并不存在的"零分文档"和"满分文档", 把通道内的相对差距人为放大。
「除以最大值」只做缩放、不做平移,保留原本的相对差距。代码在 HybridRetriever.normalize()。
第一道:融合前截断。
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 条。
| embedding(双塔) | reranker(单塔) | |
|---|---|---|
| 编码方式 | query 和文档各自编码 | (query, 文档) 拼一起同时编码 |
| 能否看到词级交互 | 不能 | 能(cross-attention) |
| 速度 | 快,文档向量可离线预计算 | 慢,每个候选都要跑一次前向 |
| 定位 | 广度召回 | 精度排序 |
所以工业界标准做法是两段式:双塔召回一批(便宜、广)→ 单塔重排这批(贵、准)。 这也解释了它为什么叫 re-rank 而不是 rank。
实测里重排的增益主要体现在 MRR(把对的往前挪),而不是 Hit Rate(捞回原本没召回的)。
服务起来后(运行-服务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。
设置:语料 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 是有限的五个位置,一个低相关候选赖在里面,就会把一个更相关的挤出榜单。 过滤不只是防幻觉的防御动作,它本身就提升指标。
送到门口时我不想要了该怎么办 → 期望「拒收与退回流程」,实际 Top1「签收验货与破损处理」
「拒收与退回流程」「签收验货与破损处理」「上门取件服务」这三篇在语义上高度重叠, 而问题又是纯口语表达("不想要了""送到门口"),目标文档的核心术语一个都没出现。 这类问题的正解是 query 改写(先让模型把口语问题规整成检索式再检索), 而不是继续调权重 —— 知道边界在哪、下一步从哪下手,比刷指标重要。
数据说明:reranker 是远程 API,同一问题两次调用可能有极细微的分数浮动, 因此 HitRate 存在 ±1 条(约 0.5%)的正常噪音。这是真实评测该有的样子, 不要期望小数第三位完全可复现。
0.45 / 0.55 不是拍脑袋来的,是拿这张表扫出来的:
向量略高于 BM25,是因为评测集里 synonym 类占比不低(32/180,17.8%)。
改 Config.W_BM25 / W_VECTOR 后重跑 运行-评测.bat 就能自己验证 ——
这是个可以当场演示的动作,比说"我调过参"有力得多。
上线时这个权重应该按线上流量分布重新扫,而不是用离线评测集的值。
第 7 节证明的是检索质量,这一节证明的是工程指标。两节的数字都能复现。
完整原始数据、对照组设计:sql/EXPLAIN验证.md、redis-验证留档.md、压测-验证留档.md、熔断与降级-验证留档.md。
启动流程:读语料 → 切片 → 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 同理。
两层缓存,分工不同(容易混淆,需要分清):
| 缓存什么 | 放哪 | 为什么 |
|---|---|---|
| 向量(文本 → 1024 个数) | 进程内(EmbeddingService 里的 TtlCache) |
大对象;一个进程算过就够了,不需要跨实例共享 |
检索结果(问题 → 完整 RetrieveResult) |
Redis(SearchCache) |
跨实例共享;命中时 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。
压测对象是 /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 反序列化 + 序列化响应。一次模型调用都没有 —— 这句是关键。
| 机制 | 实现 | 解决什么 |
|---|---|---|
| 熔断 | 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) 只是给线程打中断标记,
如果任务本身不检查中断,它还会在后台跑完。所以超时的语义是"让调用方尽快拿到失败并走降级",
不是"把任务真的杀掉"。要做到真正可中断,必须让任务内部配合。
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 里唯一能压住幻觉的地方。另外生成失败时降级返回原文,而不是让模型自由发挥。
下面是整个检索链路里最关键的六段实现。
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 —— 属于「知道取舍在哪」的主动选择。
核心检索算法刻意只用 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 是给自己看的,上线要进监控体系 |
判定标准是换它解决了什么问题,而不是用了什么新技术。
- 进程内缓存只值一层:向量缓存仍在进程内(大对象、不带跨实例需求), 检索结果缓存已经搬到 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 节有展开。