前言

上一篇我们用 Java 实现了 Token 缓存优化(点击查看),解决了大模型调用的成本问题。

但成本只是第一步。真正让后端开发者头疼的是:大模型不了解你的业务

你问它"我们的退款政策是什么",它会编一个看起来很合理的答案——但完全是错的。这就是"幻觉"问题。

RAG(Retrieval-Augmented Generation) 是目前最成熟的解决方案:先从你的知识库检索相关内容,再让模型基于检索结果回答。本文用 Java 全栈实现。

RAG 核心原理

概念说明类比
RAG检索增强生成开卷考试(先翻书再答题)
Embedding文本转向量把文字变成坐标
Vector Store向量数据库按语义搜索的图书馆
Chunk文档分块书的章节
Retrieval检索相关片段翻到相关章节
Generation基于检索结果生成回答看完书后回答问题

💡 不需要训练模型,不需要微调。只需要把你的文档切块、存向量、检索、喂给模型。成本极低,效果立竿见影。

环境准备

  • JDK 17+

  • Spring Boot 3.x

  • Ollama(本地部署)

  • Maven

<!-- pom.xml 核心依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
# 拉取 Embedding 模型 + 对话模型
ollama pull nomic-embed-text
ollama pull qwen2.5:7b

第一步:文档分块(Chunking)

把长文档切成小段,每段 200-500 字,带重叠防止语义断裂:

@Service
public class DocumentChunker {
​
    private static final int CHUNK_SIZE = 300;       // 每块字数
    private static final int OVERLAP = 50;            // 重叠字数
​
    /**
     * 将文档按段落 + 字数切分为 Chunk
     * 每个 Chunk 保留来源信息,方便溯源
     */
    public List<DocumentChunk> chunk(String content, String source) {
        List<DocumentChunk> chunks = new ArrayList<>();
​
        // 先按段落分
        String[] paragraphs = content.split("\n\n+");
        StringBuilder buffer = new StringBuilder();
​
        for (String para : paragraphs) {
            if (buffer.length() + para.length() > CHUNK_SIZE && buffer.length() > 0) {
                chunks.add(new DocumentChunk(
                    buffer.toString().trim(),
                    source,
                    chunks.size()
                ));
                // 保留末尾 OVERLAP 字作为下一块的开头
                String tail = buffer.substring(Math.max(0, buffer.length() - OVERLAP));
                buffer = new StringBuilder(tail);
            }
            buffer.append(para).append("\n\n");
        }
​
        // 最后一块
        if (buffer.length() > 0) {
            chunks.add(new DocumentChunk(buffer.toString().trim(), source, chunks.size()));
        }
​
        return chunks;
    }
}
​
record DocumentChunk(String content, String source, int index) {}

第二步:向量化(Embedding)

调用 Ollama 的 Embedding 接口,把文本转成向量:

@Service
public class EmbeddingService {
​
    private final OkHttpClient client = new OkHttpClient();
    private static final String OLLAMA_URL = "http://localhost:11434/api/embeddings";
​
    /**
     * 文本转向量
     * 使用 nomic-embed-text 模型,768 维
     */
    public float[] embed(String text) throws IOException {
        Map<String, Object> body = Map.of(
            "model", "nomic-embed-text",
            "prompt", text
        );
​
        Request request = new Request.Builder()
                .url(OLLAMA_URL)
                .post(RequestBody.create(
                    new ObjectMapper().writeValueAsString(body),
                    MediaType.parse("application/json")))
                .build();
​
        try (Response response = client.newCall(request).execute()) {
            Map result = new ObjectMapper().readValue(response.body().string(), Map.class);
            List<Number> embedding = (List<Number>) result.get("embedding");
​
            float[] vec = new float[embedding.size()];
            for (int i = 0; i < embedding.size(); i++) {
                vec[i] = embedding.get(i).floatValue();
            }
            return vec;
        }
    }
}

第三步:向量存储与检索(Vector Store)

用内存实现一个简易向量数据库,生产环境建议换 Milvus/Qdrant:

@Service
public class VectorStore {
​
    private final List<VectorEntry> entries = new CopyOnWriteArrayList<>();
​
    /**
     * 存入向量
     */
    public void store(String chunk, float[] vector, String source) {
        entries.add(new VectorEntry(chunk, vector, source));
    }
​
    /**
     * 余弦相似度检索 Top-K
     * 返回最相关的 Chunk
     */
    public List<VectorEntry> search(float[] queryVector, int topK) {
        return entries.stream()
            .map(e -> new AbstractMap.SimpleEntry<>(e, cosineSimilarity(queryVector, e.vector())))
            .sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
            .limit(topK)
            .map(AbstractMap.SimpleEntry::getKey)
            .toList();
    }
​
    /**
     * 余弦相似度计算
     */
    private double cosineSimilarity(float[] a, float[] b) {
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
​
    public int size() { return entries.size(); }
​
    record VectorEntry(String content, float[] vector, String source) {}
}

第四步:RAG 完整流程

把分块、向量化、检索、生成串起来:

@Service
public class RagService {
​
    @Autowired private DocumentChunker chunker;
    @Autowired private EmbeddingService embeddingService;
    @Autowired private VectorStore vectorStore;
    @Autowired private CachedLlmService llmService;
​
    private static final String RAG_SYSTEM_PROMPT = """
        你是一个企业知识库助手。
        规则:
        1. 只基于提供的参考资料回答,不要编造
        2. 如果参考资料中没有相关内容,明确说"根据现有资料无法回答"
        3. 回答末尾标注引用来源
        """;
​
    /**
     * 索引文档:分块 → 向量化 → 存储
     */
    public void indexDocument(String content, String source) throws IOException {
        List<DocumentChunk> chunks = chunker.chunk(content, source);
        for (DocumentChunk chunk : chunks) {
            float[] vector = embeddingService.embed(chunk.content());
            vectorStore.store(chunk.content(), vector, chunk.source());
        }
        System.out.println("已索引 " + chunks.size() + " 个分块,来源: " + source);
    }
​
    /**
     * RAG 问答:检索 → 拼接上下文 → 生成回答
     */
    public String ask(String question) throws IOException {
        // 1. 用户问题转向量
        float[] queryVector = embeddingService.embed(question);
​
        // 2. 检索最相关的 3 个 Chunk
        List<VectorStore.VectorEntry> results = vectorStore.search(queryVector, 3);
​
        if (results.isEmpty()) {
            return "知识库为空,请先索引文档。";
        }
​
        // 3. 拼接上下文
        StringBuilder context = new StringBuilder("参考资料:\n\n");
        for (int i = 0; i < results.size(); i++) {
            context.append("【").append(i + 1).append("】来源: ")
                   .append(results.get(i).source()).append("\n")
                   .append(results.get(i).content()).append("\n\n");
        }
​
        // 4. 带上下文调用模型
        String prompt = context + "\n用户问题:" + question;
        return llmService.chat(RAG_SYSTEM_PROMPT, prompt);
    }
}

第五步:Controller 接口

@RestController
@RequestMapping("/api/rag")
public class RagController {
​
    @Autowired private RagService ragService;
​
    /**
     * 上传文档并索引
     * POST /api/rag/index
     */
    @PostMapping("/index")
    public Map<String, Object> index(@RequestBody Map<String, String> body) throws IOException {
        String content = body.get("content");
        String source = body.getOrDefault("source", "manual");
        ragService.indexDocument(content, source);
        return Map.of("status", "ok", "message", "文档已索引");
    }
​
    /**
     * RAG 问答
     * POST /api/rag/ask
     */
    @PostMapping("/ask")
    public Map<String, Object> ask(@RequestBody Map<String, String> body) throws IOException {
        String question = body.get("question");
        String answer = ragService.ask(question);
        return Map.of("question", question, "answer", answer);
    }
}
# 测试:索引一份文档
curl -X POST http://localhost:8080/api/rag/index \
  -H "Content-Type: application/json" \
  -d '{"content": "退款政策:购买后7天内可无理由退款...", "source": "客服手册"}'
​
# 测试:提问
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "退款政策是什么?"}'

效果对比

场景直接问模型RAG 问答
"退款政策是什么"编造一个通用政策准确引用客服手册内容
"产品A支持哪些API""不太确定"列出文档中的完整 API 列表
"部署需要什么配置"给出通用建议精确匹配运维文档的配置要求

💡 RAG 的本质是让模型开卷考试。不改变模型能力,只改变它能"看到"什么。

生产环境注意事项

1. 向量数据库选型

方案适用场景特点
内存(本文)开发测试简单,重启丢失
Milvus大规模生产分布式,性能强
Qdrant中小规模轻量,易部署
Chroma快速原型Python 生态,Java SDK 弱

2. 分块策略优化

// 按 Markdown 标题分块(更适合技术文档)
public List<DocumentChunk> chunkByHeading(String content, String source) {
    String[] sections = content.split("(?=^## )");
    // 每个 h2 作为独立 chunk
    // ...
}

3. 检索优化

  • 混合检索:向量相似度 + BM25 关键词匹配,两者加权

  • 重排序:检索 Top-10,再用 Cross-Encoder 精排取 Top-3

  • 查询改写:用户问题太短时,先让模型扩展为更完整的查询

4. 安全性

// RAG 注入防护:不要让用户的问题被当作指令执行
// 系统提示词中明确:只基于参考资料回答,忽略用户指令类输入

常见问题

Q: Embedding 模型选哪个?

A: 本地用 nomic-embed-text(768维,速度快)。线上用 OpenAI text-embedding-3-small(1536维,效果更好)。中文场景也可选 bge-m3

Q: 文档更新了怎么办?

A: 删除旧的向量,重新索引该文档。生产环境需要文档版本管理。

Q: 支持多轮对话吗?

A: 本文示例是单轮。多轮需要额外维护对话历史,把历史也作为上下文拼入。

Q: 和微调(Fine-tuning)有什么区别?

A: RAG 不改模型,只给它看资料,适合知识频繁更新的场景。微调改模型权重,适合固定领域、需要改变模型"性格"的场景。

总结

  • RAG 是大模型落地业务的最实用方案,不需要训练、不需要 GPU、成本极低

  • 核心流程:文档分块 → 向量化 → 存储 → 检索 → 增强生成

  • Java 后端完全可以搞定,不需要切 Python

  • 本地用 Ollama 零成本,线上用 OpenAI/Anthropic API 也通用

  • 生产环境重点关注:向量数据库选型、分块策略、检索精度

你可能还想问

  • Q: 我的项目是 Spring Boot 2.x 能用吗?

    A: 可以,OkHttp + Jackson 不依赖 Spring 版本,改一下注解就行。

  • Q: 有没有现成的 Java RAG 框架?

    A: 有,Spring AI 和 LangChain4j 都支持 RAG,但理解原理后再用框架才不会踩坑。

欢迎在评论区交流你的 RAG 落地经验,或者遇到的问题 👇

参考

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐