ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java RAG实战:从零构建检索增强生成系统

Java RAG实战:从零构建检索增强生成系统 简介本资源是一个基于Java实现的RAG检索增强生成系统实战项目面向Java后端开发者、AI应用工程师及信息检索方向学习者旨在解决传统关键词检索精度低、语义理解弱的问题适用于企业知识库、智能客服、内部问答平台等场景。压缩包共266个文件含231个Java核心业务与服务类如KnowledgeBaseService、SearchService、AdiPgVectorEmbeddingStore等、15个XML配置与Spring框架定义文件、8个PNG界面与流程图、4个YML环境配置文件以及SQL建表脚本、Dockerfile和.env等部署支持文件整体大小14.32MB结构清晰、模块解耦明确。已有1486人下载学习配套完整源码与分步流程教程覆盖知识库构建、向量检索集成、LLM服务抽象及前后端协同实现帮助读者深入理解RAG在Java生态中的工程化落地路径并快速复现可运行的增强检索系统。1. 项目概述一个Java开发者的RAG实战手记最近在技术社区里RAG检索增强生成的热度居高不下。作为一个常年和Java打交道的后端工程师我最初看到这个概念时感觉它像是把搜索引擎和聊天机器人揉在了一起听起来很酷但总隔着一层“炼丹”的迷雾。市面上Python的RAG教程铺天盖地各种框架眼花缭乱但对于我们这些Java技术栈为主、更关注工程落地和系统稳定性的团队来说总感觉缺少一个“接地气”的、能从零开始讲清楚原理和实现的完整项目。所以我花了些时间基于纯Java技术栈从零搭建了一个完整的RAG系统。这个项目不是一个简单的Demo而是一个包含了知识库构建、向量检索、大模型集成的完整闭环。它解决的问题很实际如何让你手头那些PDF文档、技术手册、内部Wiki里的“死知识”变成一个能对答如流、引用准确的“智能助手”。比如你可以用它来构建一个公司内部的技术支持问答机器人或者一个基于产品文档的智能客服原型。这个项目适合谁呢首先当然是Java开发者特别是对AI应用落地感兴趣但又不想完全转向Python生态的朋友。其次是那些希望理解RAG底层机制而不只是调包的同学。项目里没有使用那些“一键部署”的臃肿框架而是用相对轻量的库把每个环节都拆解开来让你看清楚数据是怎么流的向量是怎么算的答案是怎么生成的。最后它也适合需要将RAG能力集成到现有Java服务中的团队这里的模块化设计可以直接作为参考。我会带你走完从原始文档到智能问答的整个流程包括文档加载、文本分割、向量化、向量数据库存储、语义检索以及与大模型LLM的交互。过程中遇到的坑比如文本分块的策略选择、向量模型的本机部署优化、检索结果的重排序等等我都会毫无保留地分享出来。源码和详细的流程教程已经准备好我们的目标不是跑通一个Hello World而是打造一个健壮、可扩展的RAG服务核心。2. 核心架构与设计思路拆解在动手写代码之前我们先得把RAG这栋“房子”的设计图纸画明白。一个典型的RAG系统其核心流程可以概括为“索引”和“检索生成”两个阶段。我们的Java实现也将严格遵循这个逻辑。2.1 为什么选择纯Java技术栈你可能会问Python在AI领域生态不是更成熟吗确实LangChain、LlamaIndex等框架大大降低了入门门槛。但选择Java是基于以下几个非常实际的考量工程化与稳定性很多企业的核心业务系统是Java写的。用Java实现RAG可以无缝集成到现有的Spring Cloud、Dubbo等微服务架构中享受成熟的监控、链路追踪、高可用保障。你不需要为了一个AI功能额外维护一套Python的运维体系。性能与资源控制Java在并发处理、内存管理方面有深厚的积累。对于需要处理高并发查询的RAG服务利用Java的线程池、NIO等机制可以更精细地控制资源避免Python GIL全局解释器锁可能带来的瓶颈。团队技术栈统一避免团队在两种语言间切换的成本降低学习、维护和调试的复杂度。轻量级与可控性不使用全功能框架而是组合最佳的单点工具库使得系统更轻量每一层的逻辑都掌握在自己手中便于定制和优化。当然挑战在于Java的AI生态特别是围绕本地化向量模型和LLM的生态不如Python丰富。这就需要我们做一些“桥梁”工作这也是本项目的价值所在。2.2 核心组件选型与职责我们的系统主要由以下五个核心模块构成它们像流水线上的工人各司其职文档加载与处理器 (Document Loader Processor)职责从各种来源文件系统、PDF、Word、Markdown、数据库加载原始文档并将其转换为统一的纯文本格式。选型考量我们选择了Apache Tika。它是一个内容分析工具包能解析超过一千种文件格式提取文本和元数据。对于PDF它能很好地处理文字型PDF对于扫描件则需要先经过OCR光学字符识别这可以后续作为扩展点。补充细节Tika在解析复杂格式的PDF时可能会丢失一些排版和结构信息。对于高度结构化的文档如带有多级标题的技术手册可以在文本提取后通过正则表达式或简单的规则引擎来尝试恢复章节结构这能为后续的智能分块提供上下文。文本分割器 (Text Splitter)职责将长文档切割成适合向量化模型处理的小片段Chunk。这是影响检索质量的关键一步。策略选择这里没有银弹。我们实现了两种主流策略固定长度重叠分割这是最常用的方法。例如设置块大小chunk size为500字符重叠overlap为50字符。这能保证信息的局部完整性重叠部分可以防止关键信息被割裂在两个块之间。Apache Commons Text库中的WordUtils可以帮助我们按字符数分割并保持单词完整。基于语义的分割更高级的策略。我们尝试集成一个轻量级的句子嵌入模型如sentence-transformers的Java端口或通过ONNX Runtime调用在句子边界处计算语义变化在语义自然转折点进行分割。这能产生质量更高的块但计算成本更高。实操心得对于技术文档按“章节”或“子标题”分割是最理想的。我们可以结合Tika提取的元信息如果存在或通过正则匹配“#”、“##”等Markdown标题符号来实现基于结构的递归分割。项目源码中提供了这两种分割器的实现和对比。向量化模型 (Embedding Model)职责将文本块转换为固定长度的数值向量嵌入向量。语义相似的文本其向量在空间中的距离也相近。选型与部署这是Java生态的薄弱点。我们有几个选择调用远程API如OpenAI的text-embedding-ada-002简单但产生网络延迟、依赖性和费用。本地部署开源模型我们选择这条更可控的路径。使用Deep Java Library (DJL)或ONNX Runtime来加载和运行开源的Sentence Transformer模型如all-MiniLM-L6-v2。这个模型只有80MB左右效果不错且完全可以在本地CPU上运行。DJL提供了友好的Java API来加载PyTorch或ONNX格式的模型。关键参数向量维度如384维。这决定了后续向量数据库的存储和检索效率。向量数据库 (Vector Database)职责高效存储向量并支持基于向量相似度如余弦相似度、欧氏距离的快速检索近似最近邻搜索ANN。选型我们选择了Apache Cassandra配合DataStax Astra DB的向量搜索能力或者更轻量的Milvus Lite。但为了极致简单和原型验证本项目最初版本采用了一个嵌入式方案Lucene。为什么是Lucene是的就是那个著名的全文搜索引擎。从版本8.x开始Lucene原生支持了KnnVectorField可以进行高效的向量相似度搜索。它的好处是零外部依赖完全嵌入在应用中并且能和传统的关键词搜索BM25完美结合实现混合检索。这对于RAG来说是一个巨大优势因为有些查询用关键词匹配更准。实现细节我们会创建一个Lucene索引每个文档块存储三个字段文本内容、元数据如来源、块ID以及对应的向量字段。检索时可以执行纯向量搜索也可以结合BM25分数进行加权融合。大语言模型接口 (LLM Gateway)职责接收用户问题Query和检索到的相关文本块Context组织成提示词Prompt调用大模型生成最终答案。集成方式我们设计了一个通用的LLMService接口。其实现可以是OpenAI API调用通过HTTP客户端如OkHttp调用ChatGPT。本地模型调用通过Ollama一个本地运行大模型的工具的API来调用本地部署的Llama 2、Mistral等模型。这保证了数据的完全私有化。提示词工程这是生成质量的关键。一个基础的提示词模板如下你是一个专业的助手请严格根据以下提供的上下文信息来回答问题。如果上下文信息不足以回答问题请直接说“根据已知信息无法回答该问题”。 上下文{context} 问题{question} 答案在高级版本中我们会引入多轮对话历史、要求模型引用来源等更复杂的模板。整个数据流是这样的原始文档 - (加载解析) - 纯文本 - (分割) - 文本块 - (向量化) - 向量 - (存入) - 向量数据库。这构成了“索引”阶段。当用户提问时用户问题 - (向量化) - 查询向量 - (检索) - 相关文本块 - (组装Prompt) - 调用LLM - 生成答案。这构成了“检索生成”阶段。我们的Java项目就是将这个流水线用稳定、高效的代码实现出来。3. 核心模块实现与实操要点纸上谈兵终觉浅现在我们进入实战环节看看每个核心模块的Java代码到底怎么写以及其中有哪些需要特别注意的“坑”。3.1 知识库构建从文档到向量这个阶段的目标是把一堆杂乱的文件变成向量数据库里一条条规整的、可检索的记录。3.1.1 文档加载与解析我们使用Apache Tika作为解析引擎。首先在pom.xml中添加依赖dependency groupIdorg.apache.tika/groupId artifactIdtika-core/artifactId version2.9.1/version /dependency dependency groupIdorg.apache.tika/groupId artifactIdtika-parsers-standard-package/artifactId version2.9.1/version /dependency然后实现一个通用的文档加载器import org.apache.tika.Tika; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; import java.io.IOException; import java.nio.file.Path; public class DocumentLoader { private final Tika tika new Tika(); public ParsedDocument load(Path filePath) throws IOException, TikaException { Metadata metadata new Metadata(); // 自动检测文件类型并解析内容 String content tika.parseToString(filePath, metadata); return new ParsedDocument( filePath.getFileName().toString(), content, metadata ); } } // 简单的文档数据载体 public class ParsedDocument { private String id; private String fileName; private String content; private MapString, String metadata; // 构造器、Getter/Setter省略 }注意Tika在解析某些复杂PDF时可能会内存溢出。对于大文件务必使用TikaInputStream并考虑流式解析。另外解析出的文本可能包含大量换行符和空格需要进行简单的清洗和规范化。3.1.2 文本分割策略实现如前所述我们实现一个可配置的递归字符分割器import org.apache.commons.text.WordUtils; import java.util.ArrayList; import java.util.List; public class RecursiveTextSplitter { private final int chunkSize; private final int chunkOverlap; private final String separator; public RecursiveTextSplitter(int chunkSize, int chunkOverlap) { this.chunkSize chunkSize; this.chunkOverlap Math.min(chunkOverlap, chunkSize); this.separator \n\n; // 优先按段落分割 } public ListTextChunk split(String text) { ListTextChunk chunks new ArrayList(); // 首先尝试用分隔符如双换行分割 String[] sections text.split(separator); ListString sectionsList new ArrayList(Arrays.asList(sections)); // 递归处理每个部分 for (String section : sectionsList) { if (section.length() chunkSize) { chunks.add(new TextChunk(section)); } else { // 如果部分还是太长就按句子或固定长度继续分割 chunks.addAll(splitByFixedLength(section)); } } // 处理重叠将上一个块的末尾部分添加到下一个块的开头 return createOverlappingChunks(chunks); } private ListTextChunk splitByFixedLength(String text) { ListTextChunk smallChunks new ArrayList(); int start 0; while (start text.length()) { int end Math.min(start chunkSize, text.length()); // 确保不在单词中间截断简单实现 if (end text.length()) { while (end start !Character.isWhitespace(text.charAt(end-1)) !Character.isWhitespace(text.charAt(end))) { end--; } } String chunkText text.substring(start, end); smallChunks.add(new TextChunk(chunkText)); start end - chunkOverlap; // 应用重叠 } return smallChunks; } }实操心得chunkSize和chunkOverlap是两个需要反复调试的超参数。对于通用文档500-1000字符的块大小和10%的重叠是一个不错的起点。对于代码或结构化文本可能需要更小的块。分割的质量直接决定了检索的“粒度”太大会引入噪声太小会丢失上下文。务必根据你的文档类型进行测试。3.1.3 向量化与本地模型集成这是最具挑战也最有成就感的部分。我们使用DJL来运行本地Sentence Transformer模型。首先添加DJL依赖并下载模型以ONNX格式为例dependency groupIdai.djl/groupId artifactIdapi/artifactId version0.25.0/version /dependency dependency groupIdai.djl.onnxruntime/groupId artifactIdonnxruntime-engine/artifactId version0.25.0/version /dependency你需要从Hugging Face下载all-MiniLM-L6-v2模型的ONNX格式文件包含.onnx模型文件和对应的vocab.txt词表文件放到项目的src/main/resources/models目录下。然后实现嵌入模型服务import ai.djl.Model; import ai.djl.inference.Predictor; import ai.djl.modality.nlp.bert.BertTokenizer; import ai.djl.ndarray.NDArray; import ai.djl.ndarray.NDList; import ai.djl.ndarray.NDManager; import ai.djl.translate.NoopTranslator; import ai.djl.translate.TranslateException; public class LocalEmbeddingModel implements EmbeddingService { private PredictorString, float[] predictor; private BertTokenizer tokenizer; private NDManager manager; public void init() throws IOException, ModelNotFoundException { Model model Model.newInstance(all-MiniLM-L6-v2); // 加载模型文件 Path modelDir Paths.get(src/main/resources/models/all-MiniLM-L6-v2); model.load(modelDir); manager NDManager.newBaseManager(); // 使用NoopTranslator因为输入输出我们手动处理 predictor model.newPredictor(new NoopTranslator()); // 初始化tokenizer tokenizer new BertTokenizer(modelDir.resolve(vocab.txt).toString()); } Override public float[] embed(String text) { try { // 1. 分词并转换为模型输入格式这里简化实际需要按模型要求构造input_ids, attention_mask等 // 注意这是一个简化示例真实调用需要根据模型的具体输入格式构造NDList // 对于ONNX模型可能需要使用 OnnxRuntime 引擎进行更底层的调用 // 此处为说明思路实际实现请参考DJL与ONNX模型结合的完整示例 NDManager subManager manager.newSubManager(); // ... 构造输入NDList ... // NDList input new NDList(...); // NDList output predictor.predict(input); // float[] embeddings output.get(0).toFloatArray(); // subManager.close(); // return embeddings; // 临时返回模拟向量 return new float[384]; // 实际维度为384 } catch (Exception e) { throw new RuntimeException(Embedding failed, e); } } }重要提示上述代码中的predictor.predict(...)部分是一个高度简化的示意。实际集成ONNX格式的Sentence Transformer模型需要仔细处理tokenization和模型输入输出的格式对齐。一个更可靠的做法是使用onnxruntime的Java API直接加载模型并运行推理。项目源码中提供了两种方式的完整、可运行示例。关键点在于必须确保Java端的预处理分词、填充、截断与模型训练时Python端的预处理完全一致否则生成的向量没有意义。3.2 检索核心Lucene向量索引与混合搜索我们选择Lucene作为向量存储和检索引擎因为它能同时支持向量搜索和传统关键词搜索。3.2.1 创建向量索引首先添加Lucene依赖dependency groupIdorg.apache.lucene/groupId artifactIdlucene-core/artifactId version9.8.0/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-queryparser/artifactId version9.8.0/version /dependency然后实现索引写入逻辑import org.apache.lucene.document.*; import org.apache.lucene.index.*; import org.apache.lucene.store.*; public class VectorIndexWriter { private final Directory directory; private final IndexWriter writer; private final EmbeddingService embeddingService; public VectorIndexWriter(String indexPath, EmbeddingService embeddingService) throws IOException { this.directory FSDirectory.open(Paths.get(indexPath)); IndexWriterConfig config new IndexWriterConfig(new StandardAnalyzer()); config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND); this.writer new IndexWriter(directory, config); this.embeddingService embeddingService; } public void indexDocumentChunk(TextChunk chunk, String docId, int chunkSeq) throws IOException { Document doc new Document(); // 存储文本内容用于BM25搜索和返回 doc.add(new TextField(content, chunk.getText(), Field.Store.YES)); // 存储元数据 doc.add(new StringField(docId, docId, Field.Store.YES)); doc.add(new IntPoint(chunkSeq, chunkSeq)); doc.add(new StoredField(chunkSeq, chunkSeq)); // **核心存储向量** float[] vector embeddingService.embed(chunk.getText()); // 将float数组转换为字节存储Lucene KnnVectorField的要求 // 注意Lucene 9.x 的KnnVectorField存储方式可能有变请参考最新文档 // 这里假设使用 KnnVectorField 的 fromFloats 方法 // doc.add(new KnnVectorField(vector, VectorUtil.fromFloats(vector))); // 实际代码需根据Lucene版本调整 // 存储原始向量用于可能的后续处理可选 // doc.add(new StoredField(vector_raw, VectorUtil.toBytes(vector))); writer.addDocument(doc); } public void commitAndClose() throws IOException { writer.commit(); writer.close(); directory.close(); } }3.2.2 实现混合检索向量 BM25检索时我们可能希望结合语义相似度和关键词匹配度import org.apache.lucene.search.*; import org.apache.lucene.index.*; import org.apache.lucene.search.similarities.*; public class HybridRetriever { private final IndexReader reader; private final IndexSearcher searcher; private final EmbeddingService embeddingService; public HybridRetriever(String indexPath, EmbeddingService embeddingService) throws IOException { this.reader DirectoryReader.open(FSDirectory.open(Paths.get(indexPath))); this.searcher new IndexSearcher(reader); this.embeddingService embeddingService; } public ListRetrievedChunk search(String query, int topK, float vectorWeight, float keywordWeight) throws IOException { // 1. 向量检索 float[] queryVector embeddingService.embed(query); // 构建向量查询 (示例API可能随版本变化) // Query vectorQuery new KnnVectorQuery(vector, queryVector, topK); // TopDocs vectorDocs searcher.search(vectorQuery, topK); // 2. 关键词检索 (BM25) QueryParser parser new QueryParser(content, new StandardAnalyzer()); Query keywordQuery parser.parse(QueryParser.escape(query)); // 转义特殊字符 TopDocs keywordDocs searcher.search(keywordQuery, topK); // 3. 结果融合 (简化版优先向量再补充关键词) // 更复杂的融合策略计算每个文档的加权分数 (vectorScore * vectorWeight bm25Score * keywordWeight) // 需要从两种查询中获取分数并归一化 MapString, Float combinedScores new HashMap(); // ... 实现分数融合逻辑 ... // 4. 按融合分数排序并返回 ListRetrievedChunk results new ArrayList(); // for (ScoreDoc scoreDoc : combinedTopDocs) { // Document doc searcher.doc(scoreDoc.doc); // results.add(new RetrievedChunk(doc.get(content), doc.get(docId), ...)); // } return results.subList(0, Math.min(topK, results.size())); } }注意事项Lucene的KNN向量搜索在9.x版本后成为核心功能但API仍在演进。务必查阅你所使用版本的官方文档。混合检索的分数融合Score Fusion是一个研究点简单的线性加权可能不够可以尝试RRFReciprocal Rank Fusion等算法。项目源码中提供了更稳健的实现。3.3 生成层与大模型对话检索到相关文本块后我们需要将它们和用户问题一起送给大模型。3.3.1 构建提示词与调用LLM我们定义一个LLM服务接口并实现一个基于Ollama本地的版本public interface LLMService { String generateAnswer(String userQuestion, ListString contexts); } public class OllamaLLMService implements LLMService { private final String modelName; // 如 llama2:7b private final String baseUrl; // 如 http://localhost:11434 private final OkHttpClient client new OkHttpClient(); Override public String generateAnswer(String userQuestion, ListString contexts) { // 1. 构建Prompt String promptTemplate 请根据以下上下文信息回答问题。如果上下文不包含答案请直接说“根据已知信息无法回答该问题”。 上下文 %s 问题%s 答案 ; String combinedContext String.join(\n\n, contexts); String finalPrompt String.format(promptTemplate, combinedContext, userQuestion); // 2. 调用Ollama API String requestBody String.format({\model\: \%s\, \prompt\: \%s\, \stream\: false}, modelName, finalPrompt.replace(\, \\\)); Request request new Request.Builder() .url(baseUrl /api/generate) .post(RequestBody.create(requestBody, MediaType.get(application/json))) .build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) throw new IOException(Unexpected code response); String responseBody response.body().string(); // 解析Ollama的JSON响应 ObjectMapper mapper new ObjectMapper(); JsonNode root mapper.readTree(responseBody); return root.path(response).asText(); } catch (IOException e) { throw new RuntimeException(Failed to call Ollama API, e); } } }3.3.2 上下文管理与优化直接拼接所有检索到的上下文可能超出模型的令牌限制。我们需要进行优化public class ContextManager { private final int maxContextLength; // 最大令牌数需根据模型调整 public String buildOptimizedContext(ListRetrievedChunk chunks, String question) { // 按相关性分数排序 chunks.sort(Comparator.comparing(RetrievedChunk::getScore).reversed()); StringBuilder contextBuilder new StringBuilder(); for (RetrievedChunk chunk : chunks) { String candidateContext contextBuilder.toString() \n chunk.getText(); // 简单估算令牌数更准确需用tokenizer if (estimateTokens(candidateContext) maxContextLength) { break; } contextBuilder.append(chunk.getText()).append(\n\n); } return contextBuilder.toString(); } private int estimateTokens(String text) { // 简单估算英文约1 token ~ 4字符中文约1-2字符。生产环境应使用模型对应的tokenizer。 return text.length() / 3; } }实操心得提示词Prompt是RAG的“灵魂”。除了基础的上下文和问题可以尝试角色设定“你是一个专业的Java技术专家...”格式要求“请用分点列表的形式回答。”引用来源“在回答中请注明信息来源于哪个文档的第几块。”这需要我们在上下文中嵌入块标识思维链“请先一步步推理然后给出最终答案。”多实验不同的Prompt模板对答案质量的影响可能比换模型还大。4. 项目集成与完整流程演示现在我们把所有模块像拼积木一样组装起来形成一个完整的、可运行的RAG服务。我将以一个Spring Boot Web应用为例展示如何提供索引和问答的API。4.1 服务架构与API设计我们创建一个简单的Spring Boot应用提供两个核心端点POST /api/index接收文档文件如PDF触发索引流程。POST /api/query接收用户问题返回智能答案。项目结构大致如下src/main/java/com/example/rag/ ├── RagApplication.java ├── controller/ │ ├── IndexController.java │ └── QueryController.java ├── service/ │ ├── DocumentProcessingService.java // 编排加载、分割、向量化、存储 │ ├── RetrievalService.java // 负责检索 │ ├── GenerationService.java // 负责调用LLM生成 │ ├── EmbeddingService.java │ └── LLMService.java ├── repository/ // 向量索引访问层 └── config/ // 组件配置4.1.1 索引服务编排DocumentProcessingService是索引流程的总指挥Service public class DocumentProcessingService { Autowired private DocumentLoader docLoader; Autowired private TextSplitter textSplitter; Autowired private EmbeddingService embeddingService; Autowired private VectorIndexRepository vectorRepo; Transactional // 如果涉及数据库操作 public void processAndIndexDocument(MultipartFile file) throws Exception { // 1. 保存临时文件 Path tempFile Files.createTempFile(rag-upload-, file.getOriginalFilename()); file.transferTo(tempFile.toFile()); try { // 2. 加载解析 ParsedDocument parsedDoc docLoader.load(tempFile); String docId UUID.randomUUID().toString(); // 3. 文本分割 ListTextChunk chunks textSplitter.split(parsedDoc.getContent()); // 4. 批量向量化并存储 (为提高效率可批量处理) ListVectorChunk vectorChunks new ArrayList(); for (int i 0; i chunks.size(); i) { TextChunk chunk chunks.get(i); float[] vector embeddingService.embed(chunk.getText()); VectorChunk vc new VectorChunk(); vc.setId(docId _ i); vc.setDocId(docId); vc.setContent(chunk.getText()); vc.setVector(vector); vc.setMetadata(Map.of(fileName, parsedDoc.getFileName(), chunkSeq, String.valueOf(i))); vectorChunks.add(vc); } // 5. 存入向量数据库 vectorRepo.batchUpsert(vectorChunks); } finally { // 清理临时文件 Files.deleteIfExists(tempFile); } log.info(文档 {} 索引完成共生成 {} 个块。, file.getOriginalFilename(), chunks.size()); } }4.1.2 问答链服务编排RetrievalService和GenerationService协同完成问答Service public class QAService { Autowired private RetrievalService retrievalService; Autowired private GenerationService generationService; Autowired private ContextManager contextManager; public AnswerResult answerQuestion(String question) { long start System.currentTimeMillis(); // 1. 检索相关文本块 ListRetrievedChunk relevantChunks retrievalService.retrieve(question, 5); // 取Top5 log.debug(检索到 {} 个相关片段, relevantChunks.size()); if (relevantChunks.isEmpty()) { return new AnswerResult(未找到相关信息。, Collections.emptyList()); } // 2. 优化上下文适应模型长度限制 String optimizedContext contextManager.buildOptimizedContext(relevantChunks, question); // 3. 调用LLM生成答案 String answer generationService.generateAnswer(question, optimizedContext); // 4. 组装结果可包含引用来源 ListString sources relevantChunks.stream() .map(RetrievedChunk::getSourceInfo) .collect(Collectors.toList()); long cost System.currentTimeMillis() - start; log.info(问答完成耗时 {} ms, cost); return new AnswerResult(answer, sources); } }4.2 配置与启动在application.yml中我们需要配置各个组件rag: embedding: model-path: classpath:/models/all-MiniLM-L6-v2.onnx type: local # 或 openai llm: type: ollama # 或 openai ollama: base-url: http://localhost:11434 model: llama2:7b openai: api-key: ${OPENAI_API_KEY} model: gpt-3.5-turbo index: path: ./data/lucene-index chunk-size: 500 chunk-overlap: 50启动应用后你可以使用curl或 Postman 进行测试# 1. 索引文档 curl -X POST -F file/path/to/your/java-tutorial.pdf http://localhost:8080/api/index # 2. 进行问答 curl -X POST http://localhost:8080/api/query \ -H Content-Type: application/json \ -d {question:Java中HashMap的工作原理是什么}预期的返回结果应该是一个JSON包含生成的答案和引用的文档片段来源。4.3 效果评估与迭代一个RAG系统搭建完成后如何判断它好不好不能只靠感觉。我们需要一些评估方法人工评估准备一组标准问题QA对让人去判断答案的准确性、相关性和流畅度。这是最可靠但成本最高的方法。自动指标检索召回率RecallK对于一个问题前K个检索结果中包含标准答案的比例。这衡量了检索模块的好坏。答案相似度使用一个文本相似度模型如你正在用的嵌入模型计算生成答案与标准答案的余弦相似度。LLM作为裁判用一个大模型如GPT-4来评判生成答案的质量。可以设计Prompt如“请判断‘答案’是否正确地回答了‘问题’并且其信息是否严格基于‘上下文’。只输出‘是’或‘否’。”基于评估结果你可以有针对性地迭代如果检索不准调整文本分割策略块大小、重叠、尝试不同的嵌入模型、优化混合检索的权重、或引入**重排序Re-ranking**模型对初步检索结果进行精排。如果生成答案不好优化Prompt模板、尝试不同的LLM、或者在上下文中提供更多指引信息如“请优先参考以下片段1和3”。如果速度慢对向量索引进行优化如使用HNSW图索引、对嵌入模型进行量化、或对LLM的生成进行缓存。5. 常见问题、排查技巧与进阶优化在实际开发和运行中你一定会遇到各种各样的问题。这里我记录了一些典型的“坑”和解决思路希望能帮你节省大量调试时间。5.1 常见问题速查表问题现象可能原因排查步骤与解决方案向量化过程非常慢1. 模型首次加载慢。2. 在CPU上运行且文本太长。3. 没有批量处理。1. 启动时预加载模型。2. 考虑使用GPUDJL支持或更小的模型如all-MiniLM-L6-v2已很小。3. 实现批量嵌入Batch Embedding一次性处理多个文本块效率远高于循环单条处理。检索结果完全不相关1. 嵌入模型不匹配如用中文模型处理英文。2. 文本分割不合理破坏了语义。3. 向量索引构建或查询有误。1. 确认模型训练语种和你的文档语种一致。2. 检查分割后的文本块看是否在句子中间被切断。尝试基于标点或语义分割。3. 写入和查询时确保向量维度一致。检查Lucene中KNN查询的语法是否正确。LLM回答“根据已知信息无法回答”但明明检索到了相关上下文1. Prompt指令不清晰。2. 上下文太长或格式混乱模型无法理解。3. 检索到的上下文质量不高包含矛盾或无关信息。1. 强化Prompt如“你必须使用以下上下文”、“如果上下文足够禁止说无法回答”。2. 优化上下文管理清理多余空格、换行确保上下文连贯。3. 提高检索的Top K值或使用重排序模型筛选出最相关的1-2条。服务内存占用过高频繁GC1. 一次性加载大量文档进行索引。2. 向量模型或LLM模型常驻内存过大。3. 检索时加载了过多向量数据到内存。1. 索引过程采用流式或分批次处理。2. 对于LLM考虑使用API方式而非本地加载超大模型。3. 确保Lucene索引是基于磁盘的查询时只加载必要的部分。监控JVM堆内存调整-Xmx参数。混合检索效果不如纯向量检索1. BM25和向量分数的权重设置不合理。2. 关键词权重过高对语义查询造成干扰。1. 在验证集上网格搜索Grid Search最佳权重组合vectorWeight, keywordWeight。2. 尝试更先进的融合算法如RRF它不依赖绝对分数只依赖排名。5.2 进阶优化技巧当你跑通基础流程后这些优化可以让你的RAG系统更强大、更智能元数据过滤在检索时除了语义还可以加入过滤器。例如用户问“2023年的财务报告”你可以让检索只在metadata.year2023且metadata.docTypereport的块中进行。这在Lucene中可以通过BooleanQuery组合KnnVectorQuery和TermQuery实现。查询扩展Query Expansion用户的原始查询可能很短或不精确。你可以先用LLM对查询进行改写或扩展。例如将“怎么用HashMap”扩展为“Java中HashMap的使用方法、初始化、遍历和线程安全性”。再用扩展后的查询去做向量化检索效果会好很多。重排序Re-ranking初步检索召回可能返回10-20个相关块但其中真正有用的可能只有前3个。可以引入一个轻量级的**交叉编码器Cross-Encoder**模型如ms-marco-MiniLM-L-6-v2它对“查询-段落”对进行精细的相关性打分比单纯的向量相似度更准。将初步检索的结果用重排序模型重新打分排序再将Top3送给LLM能显著提升答案质量。多轮对话Conversational RAG要让系统记住历史对话。一种简单方法是将历史问答也作为上下文的一部分。更高级的做法是在向量化用户当前问题时将历史对话的摘要或上一轮LLM的输出也考虑进去形成一个“对话感知”的查询向量。Agentic RAG这是更前沿的思路。让LLM扮演“调度员”的角色它自己决定是否需要检索、检索什么、以及如何组合多个检索结果。例如对于一个复杂问题“比较Java和Python在数据科学领域的优劣”Agent可以将其拆解成两个子查询“Java数据科学生态”和“Python数据科学生态”分别检索再综合生成答案。这个基于Java的RAG项目从零到一的搭建过程就像在组装一台精密的仪器。每一个环节——文档解析、文本分割、向量化、检索、生成——都需要仔细调校。它没有Python生态中那些“开箱即用”的便利但也因此让你对RAG的底层机理有了更深刻的理解。当你看到自己搭建的系统能够从一堆冰冷的文档中准确地找出并生成你需要的答案时那种成就感是无可替代的。项目的完整源码和更细致的配置说明我已经整理好希望能成为你在智能应用开发路上的一个坚实起点。记住最好的优化永远来自于对业务场景的深入理解和对效果的持续评估。本文还有配套的精品资源点击获取
返回列表