Java搭RAG实战:混合检索+重排序:完整源码+逐行设计决策

📅 2026/7/17 22:48:07 👁️ 阅读次数 📝 编程学习
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,用错了会报错,排查半天才定位到。

降级逻辑:三级降级

  1. 配置层禁用 → 返回原始顺序
  2. API返回非200 → 返回原始顺序
  3. 异常捕获 → 返回原始顺序

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 Rerankv2 Rerank
中文语义"介绍Dream-SaaS"0.26820200.5240.879
专有名词"code-review-agent"0.43420200.5240.860
中文关键词"向量数据库选型"0.5380200.5170.514
模糊语义"怎么让AI基于知识库回答"0.4460200.4600.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时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~

这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费