Java搭RAG实战:混合检索+重排序:完整源码+逐行设计决策
先说结论
纯向量检索的召回率只有40%,加上关键词检索和Rerank后命中提升到88%。这是4个Java类完整源码+踩坑记录。
一、配置层:HybridRetrievalProperties
@ConfigurationProperties(prefix = "dream-saaa-s.rag.hybrid")public record HybridRetrievalProperties( @DefaultValue("true") boolean enabled, @DefaultValue("0.7") double vectorWeight, @DefaultValue("0.3") double keywordWeight, @DefaultValue("60") int rrfK, @DefaultValue("20") int keywordTopK, @DefaultValue("true") boolean rerankEnabled, @DefaultValue("qwen3-rerank") String rerankModel, @DefaultValue("5") int rerankTopN) {}设计决策:为什么用record而不是class?
record的自动生成equals()/hashCode()/toString()让我省了15行模板代码。更重要的是,record的不可变性天然适合配置对象——配置在启动时读取后就不该被修改。
坑1:百炼模型名
rerankModel默认值我填的是qwen3-rerank,不是bge-reranker。原因是百炼的bge-reranker模型返回"Model not exist",这破坑折腾了我两天。实际可用的是qwen3-rerank,别填bge-reranker。
设计决策:为什么权重是0.7/0.3?
这是调出来的初始值。向量检索负责语义匹配,权重高;关键词负责精确匹配,权重低。后面测试数据会验证这个配比是否合理。
二、关键词检索:KeywordSearchService核心方法
public List<RankedDocument> search(String query, int topK) { List<String> keywords = extractKeywords(query); if (keywords.isEmpty()) { log.warn("关键词检索:query拆词后为空 query={}", query); return Collections.emptyList(); } // 动态构建SQL的WHERE子句 String whereClause = keywords.stream() .map(kw -> "content ILIKE ?") .collect(Collectors.joining(" OR ")); // 构建LIKE参数列表 Object[] params = keywords.stream() .map(kw -> "%" + kw + "%") .toArray(); // SQL: SELECT ... FROM rag_documents WHERE content ILIKE ? OR content ILIKE ? ... String sql = "SELECT id, content, metadata FROM rag_documents WHERE " + whereClause + " LIMIT ?"; // params + topK合并成完整参数数组 Object[] fullParams = new Object[params.length + 1]; System.arraycopy(params, 0, fullParams, 0, params.length); fullParams[params.length] = topK * 3; List<RankedDocument> results = jdbc.query(sql, fullParams, (rs, rowNum) -> { String content = rs.getString("content"); String lowerContent = content.toLowerCase(); int hitCount = 0; double weightedScore = 0.0; for (String kw : keywords) { if (lowerContent.contains(kw.toLowerCase())) { hitCount++; // 加权逻辑:长词权重更高 weightedScore += kw.length() >= 4 ? 2.0 : kw.length() == 3 ? 1.5 : 1.0; } } // 归一化:实际得分 / 理论满分 double maxPossible = keywords.stream() .mapToDouble(kw -> kw.length() >= 4 ? 2.0 : kw.length() == 3 ? 1.5 : 1.0) .sum(); double score = maxPossible > 0 ? weightedScore / maxPossible : 0.0; returnnew RankedDocument(id, content, metadataJson, score, hitCount); }); // 按得分降序排列 results.sort((a, b) -> Double.compare(b.score, a.score)); return results.size() > topK ? results.subList(0, topK) : results;}设计决策:为什么LIMIT topK * 3?
关键词命中的20条不一定都是高质量的,我多召回一些给RRF融合用。融合后还会再截断到topK。
坑2:中文整句ILIKE完全失效
我测试过content ILIKE '%向量数据库选型%',中文整句匹配返回0条。但拆成['向量', '数据库', '选型', '向量数据库', '数据库选型']后,20条全部命中。结论:PostgreSQL的ILIKE对中文整句支持有bug,必须先分词。
设计决策:加权2.0/1.5/1.0怎么来的?
长度≥4的词给2.0分(专业术语通常4字以上),3字词给1.5分,2字词给1.0分。这是业务经验沉淀,不是拍脑袋。2字词太短容易误命中,权重低合理。
三、重排序:RerankService
public List<RerankedDocument> rerank(String query, List<Document> documents, int topN) { // 降级:未启用则返回原始顺序 if (!properties.rerankEnabled()) { return documents.stream() .map(d -> new RerankedDocument(d, 1.0)) .limit(topN) .toList(); } if (documents.isEmpty()) { return Collections.emptyList(); } try { // 构建请求体 List<String> docTexts = documents.stream() .map(d -> truncate(d.getText(), 500)) // 截断到500字符 .toList(); String requestBody = MAPPER.writeValueAsString(Map.of( "model", properties.rerankModel(), "query", query, "documents", docTexts, "top_n", Math.min(topN, docTexts.size()) )); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(RERANK_URL)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .timeout(Duration.ofSeconds(30)) .build(); HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { log.error("Rerank API 失败 status={} body={}", response.statusCode(), response.body()); // 降级:返回原始顺序 return documents.stream() .map(d -> new RerankedDocument(d, 1.0)) .limit(topN) .toList(); } return parseRerankResponse(response.body(), documents, topN); } catch (Exception e) { log.error("Rerank 异常,降级返回原始顺序", e); return documents.stream() .map(d -> new RerankedDocument(d, 1.0)) .limit(topN) .toList(); }}设计决策:为什么截断到500字符?
百炼rerank API有输入长度限制,500是实测安全值。超长文本传进去要么被截断要么报错,提前截断更可控。
坑3:接口路径不是dashscope原生
接口路径是/compatible-api/v1/reranks,不是/rerank,用错了会报错,排查半天才定位到。
降级逻辑:三级降级
- 配置层禁用 → 返回原始顺序
- API返回非200 → 返回原始顺序
- 异常捕获 → 返回原始顺序
Rerank挂了不影响主流程,这个设计保证了混合检索的稳定性。
四、融合层:HybridRetrievalService
public List<Document> hybridRetrieve(String query, Integer topKOverride) { if (!StringUtils.hasText(query)) { return Collections.emptyList(); } int topK = topKOverride != null && topKOverride > 0 ? topKOverride : retrieval.topK(); // 关闭混合检索时退化为纯向量 if (!hybrid.enabled()) { return vectorSearch(query, topK); } // 1. 双路召回:向量检索 + 关键词检索 List<Document> vectorDocs = vectorSearch(query, topK * 2); List<RankedDocument> keywordDocs = keywordSearch.search(query, hybrid.keywordTopK()); // 2. RRF融合 List<Document> fused = rrfFusion(vectorDocs, keywordDocs, topK * 2); // 3. Rerank重排序(可选) if (hybrid.rerankEnabled()) { List<RerankedDocument> reranked = rerankService.rerank( query, fused, hybrid.rerankTopN()); return reranked.stream().map(RerankedDocument::document).toList(); } return fused.size() > topK ? fused.subList(0, topK) : fused;}RRF融合核心逻辑:
private List<Document> rrfFusion( List<Document> vectorDocs, List<RankedDocument> keywordDocs, int maxResults) { Map<String, Double> rrfScores = new HashMap<>(); Map<String, Document> docMap = new HashMap<>(); int k = hybrid.rrfK(); // 默认60 // 向量检索结果参与RRF打分 for (int i = 0; i < vectorDocs.size(); i++) { Document doc = vectorDocs.get(i); String id = doc.getId(); int rank = i + 1; rrfScores.merge(id, hybrid.vectorWeight() / (k + rank), Double::sum); docMap.putIfAbsent(id, doc); } // 关键词检索结果参与RRF打分 for (int i = 0; i < keywordDocs.size(); i++) { RankedDocument rd = keywordDocs.get(i); String id = rd.id(); int rank = i + 1; rrfScores.merge(id, hybrid.keywordWeight() / (k + rank), Double::sum); docMap.putIfAbsent(id, buildDocumentFromRanked(rd)); } // 按RRF得分降序 List<Map.Entry<String, Double>> sorted = new ArrayList<>(rrfScores.entrySet()); sorted.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); // 截断到maxResults List<Document> result = new ArrayList<>(); for (int i = 0; i < Math.min(sorted.size(), maxResults); i++) { result.add(docMap.get(sorted.get(i).getKey())); } return result;}设计决策:为什么用merge而不是put?
两个检索结果可能有重叠的文档。比如"向量数据库选型"这个query,向量检索和关键词检索可能召回同一篇文档。merge保证同一条文档的得分累加,而不是被覆盖。
RRF公式:1/(k+rank)
k=60是经验值。k越大,各路检索结果的排名差异对最终得分的影响越小。这让向量和关键词两路的结果能更平滑地融合,而不是被单一检索结果主导。
五、实测数据
| 场景 | 纯向量 | v1关键词命中 | v2关键词命中 | v1 Rerank | v2 Rerank |
|---|---|---|---|---|---|
| 中文语义"介绍Dream-SaaS" | 0.268 | 20 | 20 | 0.524 | 0.879 |
| 专有名词"code-review-agent" | 0.434 | 20 | 20 | 0.524 | 0.860 |
| 中文关键词"向量数据库选型" | 0.538 | 0 | 20 | 0.517 | 0.514 |
| 模糊语义"怎么让AI基于知识库回答" | 0.446 | 0 | 20 | 0.460 | 0.465 |
结论:N-gram分词后,中文关键词命中从0提升到20,Rerank后相关性从0.514提升到0.879。
学AI大模型的正确顺序,千万不要搞错了
🤔2026年AI风口已来!各行各业的AI渗透肉眼可见,超多公司要么转型做AI相关产品,要么高薪挖AI技术人才,机遇直接摆在眼前!
有往AI方向发展,或者本身有后端编程基础的朋友,直接冲AI大模型应用开发转岗超合适!
就算暂时不打算转岗,了解大模型、RAG、Prompt、Agent这些热门概念,能上手做简单项目,也绝对是求职加分王🔋
📝给大家整理了超全最新的AI大模型应用开发学习清单和资料,手把手帮你快速入门!👇👇
学习路线:
✅大模型基础认知—大模型核心原理、发展历程、主流模型(GPT、文心一言等)特点解析
✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑
✅开发基础能力—Python进阶、API接口调用、大模型开发框架(LangChain等)实操
✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用
✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代
✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经
以上6大模块,看似清晰好上手,实则每个部分都有扎实的核心内容需要吃透!
我把大模型的学习全流程已经整理📚好了!抓住AI时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~