Java 转 AI 应用开发之RAG + Spring AI 简单实践案例

本文是对「RAG 基础概念 + 本地知识库 Demo」的整理稿:上半部分讲清楚 什么是 RAG流程图在说什么;下半部分保留可运行的 Spring AI + 智谱 示例代码,并补一段 为何不精准往生产走时多考虑啥
适合已经会写 Spring Boot、正准备接大模型做企业内部问答的 Java 同学。


1. 一句话定义

RAG(Retrieval-Augmented Generation,检索增强生成):在让大模型 生成答案之前,先从你自己的知识源里 检索 一段相关内容,塞进 Prompt,再交给 LLM 生成。这样模型既用得上 最新、私有、领域 的材料,又能在一定程度上抑制 空口编造(幻觉)。

它不是替代微调,而是 外挂图书馆:考试允许翻书,翻到的页码就是检索到的 chunk。


2. RAG 在干什么:对照流程图理解

下面这张示意图把 RAG 拆成两条时间线:离线建索引在线问答案。原图常见于各类 RAG 教程,这里在仓库中的引用路径为:

image.png

2.1 离线索引(Indexing,图左侧虚线框)

步骤 在干什么
docs 原始材料:URL、PDF、TXT、数据库导出等,本质是你的 本地 / 企业知识库
Parsing + preprocessing 解析格式、清洗噪声,变成可切的纯文本。
Chunking 切成 小块(chunks):模型上下文有限,且小块更利于 精准命中 某一段说法。
Embedding Model 把每个 chunk 变成 向量(高维数值),语义相近的文本在向量空间里通常 离得近
Vector store + Indexing 向量(常附带原文、元数据)写入 向量数据库或索引,供后续检索。

个人理解(Java 老鸟的直觉):这一步像给海量日志做 倒排索引,只不过索引键从「词」换成了 语义向量;建索引进度慢、占存储,但问的时候是在 近邻搜索,不是在全库扫字符串。

2.2 在线检索与生成(Retrieval & Generation,图右侧)

步骤 在干什么 白话解释(中文)
query 用户问题。 用户用自然语言提问,是整个 在线阶段 的入口;后面所有步骤都围绕「这句话要找什么、答什么」。
Embedding Model(Vectorize) 问题也编成 同维度向量,和库里的向量 可比 把「一句话」变成 一串数字(向量),和建库时 chunk 用的 同一套模型、同一维度,才能在同一空间里谈「近不近」。
Retrieve 在向量库里做 相似度检索(常见:余弦相似度、点积;库内往往用 HNSW 等索引加速)。 在知识库里 按语义远近 捞出与问题最相关的若干段材料,相当于 翻书翻到最相关那几页(不必整本书扫字)。
Relevant docs + prompt Top-K 片段拼进提示词模板,约束模型 只结合给定上下文作答 把捞出来的片段当 参考资料,写进 Prompt,告诉模型:先信这些,再组织语言;减少空口编造。
LLM → Generate → response 大模型推理,输出连贯回答。 大模型在 给定上下文 + 用户问题 下做生成,输出用户看得懂的 最终答复(仍可能需后处理与审核)。

名词:HNSW 指什么?

HNSWHierarchical Navigable Small World)是向量检索里常用的一种 近似最近邻(ANN) 算法:在高维向量空间里建 分层图索引,查询时从上层「粗跳」快速接近目标区域,再在下层细查,从而在 不全表暴力扫描 的前提下,尽快找到与查询向量 最接近 的若干条记录。

  • 为何需要它:全库两两算相似度是 O(数据量),数据一大就不可用;向量库(Milvus、Qdrant、pgvector 等)内部用 HNSW、IVF 等索引做 加速召回
  • 近似:结果是 近似 最近邻,用少量精度换 延迟与吞吐;重要场景可再叠 Rerank 精排。
  • 和 Demo 的关系:教程里 for 循环算余弦相似度是 朴素全表;生产应交给 带 ANN 索引的 Vector Store,而不是在应用里自己扫 List
落地实现:从「手写循环」到「带索引的 Vector Store」

1. 典型数据流(索引何时出现)

  1. 建库 / 灌库:文档 chunk → EmbeddingModel.embed() → 向量 → VectorStore.add / addAll;存储在落盘后 构建或更新 HNSW(或 IVF 等),后续查询 不再 对全量向量做朴素两两比较。
  2. 在线检索:用户问题 → 同维度 query 向量 → VectorStore.similaritySearch(SearchRequest)(或各实现类等价 API)→ 返回 Top-K Document
  3. 距离度量:与库侧索引 ops 一致(余弦 / L2 / 内积),且与 Embedding 模型是否归一化 的假设一致,否则召回会飘。

2. Spring AI 中的抽象

  • VectorStore:屏蔽 PgVectorStoreMilvusVectorStoreSimpleVectorStore 等;业务只面对 Document(text + metadata)SearchRequest(query / topK / filter)
  • SimpleVectorStore:多作小数据、单测或本地 demo;不等于 生产级持久化 + HNSW,但可快速验证 RAG 链路
  • PgVector / Milvus / Qdrant:在库里 建表 + 建向量索引(部分由 starter 自动 DDL,部分需你按厂商文档先建 HNSW)。

3. 各后端「HNSW」长什么样(概念级)

后端 实现要点
pgvector 列类型如 vector(1536);索引示例:CREATE INDEX … ON tbl USING hnsw (embedding vector_cosine_ops);ops 与距离一致)。可调 mef_construction 等(以 pgvector 当前文档为准)。
Milvus Collection 上 INDEXindex_type 常选 HNSWparamsMefConstruction;查询侧 ef 影响精度与延迟。
Qdrant 向量参数 + HNSW(如 mef_construct),查询可设 hnsw_ef

4. 调参直觉

  • M:邻居多 → 召回更好,建索引 更慢占内存更多
  • efConstruction / ef(查询):搜索范围大 → 更准更慢
  • Top-K:可先 略大(如 20)再 Rerank 压到 5,比单次 K=2 稳。

5. 代码形态(示意)

// 灌库:chunk → Document(metadata 带 source、chunkId) → add
// vectorStore.add(List.of(new Document(text, Map.of("source", "poetry.txt"))));

// 检索:由 VectorStore 内部调 Embedding 或传入 query 文本(视实现)
// List<Document> hits = vectorStore.similaritySearch(
//     SearchRequest.builder().query("古代诗歌常用意象有哪些?").topK(8).build());

6. 与本文 Demo 的对应

Demo 生产
List<float[]> + for + cosineSimilarity VectorStore + 库内 ANN 索引
内存 docs 持久化表 + metadata,向量可 重建

探究:RAG 解决的是 「知识从哪来」「话怎么说得体」 仍靠 Prompt、模型能力和后处理。上下文给错了,模型照样能一本正经胡说——所以 检索质量 往往是瓶颈,而不是 Chat API 调得花不花。


3. Demo 案例目标(Spring AI + 智谱)

案例中用到:

  • Spring AI:统一抽象 EmbeddingModelChatClient 等,类似当年用 JdbcTemplate 统一数据源。
  • 智谱 AI Embedding:把文本变成向量。
  • 智谱 Chat(如 GLM-4-Flash):结合检索到的片段回答问题。

实现思路(与教程一致):

  1. 本地知识文件放在 resourcesClassPathResource 加载。
  2. 按分隔符 ---- 手工切分片段(教学向;生产慎用这种粗切法)。
  3. 启动时对每段 embed,向量放 内存列表(教学向;重启即失、量大必挂)。
  4. 用户提问 embed 后,与所有片段向量算 余弦相似度,取 Top-K(示例代码里 TOP_K = 3,可按题调整)拼上下文。
  5. 系统提示 + 用户提示 交给 ChatClient 同步调用返回文案。

4. 配置与资源

4.1 Chat 模型(application.properties

# 使用智谱 AI Chat 模型(pom.xml 需引入对应 spring-ai-starter 与智谱 BOM/依赖)
spring.ai.zhipuai.chat.options.model=GLM-4-Flash

4.2 本地知识文件

resources 下放置 古代诗歌常用意象.txt(注意原文小标题里有时写作「意向」,文件名建议统一为 意象,与诗文术语一致)。

内容结构上,文档用 ---- 分成多块,涵盖 植物类 / 动物类 / 景象类 / 人文类 等意象说明——Demo 里 每一块会被整体 embed 一次,块太大或切法不对会直接影响检索颗粒度。


5. 核心代码:RagService

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Service
public class RagService {

    /** 检索拼进 Prompt 的片段条数;枚举类问题可适当调大 */
    private static final int TOP_K = 3;

    private final EmbeddingModel em; // 嵌入模型,用于生成文本的向量
    private final ChatClient chatClient; // 聊天客户端,用于与 AI 进行交互
    private final List<String> docs = new ArrayList<>();// 存储本地文档内容
    private final List<float[]> vectors = new ArrayList<>();// 存储本地文档向量

    public RagService(EmbeddingModel embeddingModel, ChatClient.Builder chatBuilder) throws IOException {
        this.em = embeddingModel;
        this.chatClient = chatBuilder.build(); // 创建智谱 AI Chat 客户端

        Resource res = new ClassPathResource("古代诗歌常用意象.txt");
        String content = new String(res.getInputStream().readAllBytes(), StandardCharsets.UTF_8);

        for (String part : content.split("----")) {
            System.out.println("part: " + part);
            if (part.isBlank()) continue;
            docs.add(part);
            vectors.add(em.embed(part)); // 将文档生成 embedding 并存储
        }
    }

    public String answer(String q) {
        if (vectors.isEmpty()) {
            return "知识库为空。";
        }

        float[] qv = em.embed(q);
        int k = Math.min(TOP_K, vectors.size());
        List<Integer> topIndices = topKSimilarIndices(qv, k);

        StringBuilder ctx = new StringBuilder();
        for (int i = 0; i < topIndices.size(); i++) {
            if (i > 0) {
                ctx.append("\n---\n");
            }
            ctx.append(docs.get(topIndices.get(i)));
        }

        String prompt = "以下是知识内容:\n" + ctx + "\n请基于上述知识回答用户问题:「" + q + "」";

        var response = chatClient
                .prompt()
                .system("你是知识助手,结合上下文回答问题")
                .user(prompt)
                .call();

        return response.content();
    }

    /**
     * Top-K:先对每个 chunk 算与问题的余弦相似度,再按分数降序取前 k 个下标。
     * 数据量极大时可改为「最小堆维护前 K」避免全量排序,此处教学向保持可读。
     */
    private List<Integer> topKSimilarIndices(float[] queryVector, int k) {
        int n = vectors.size();
        double[] score = new double[n];
        for (int i = 0; i < n; i++) {
            score[i] = cosineSimilarity(queryVector, vectors.get(i));
        }
        Integer[] order = new Integer[n];
        for (int i = 0; i < n; i++) {
            order[i] = i;
        }
        Arrays.sort(order, (i, j) -> Double.compare(score[j], score[i]));
        List<Integer> top = new ArrayList<>(k);
        for (int i = 0; i < k; i++) {
            top.add(order[i]);
        }
        return top;
    }

    // 余弦相似度:值域通常在 [-1, 1],越接近 1 越相似(向量若已 L2 归一化则点积即余弦)
    private double cosineSimilarity(float[] a, float[] b) {
        double dot = 0, na = 0, nb = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            na += a[i] * a[i];
            nb += b[i] * b[i];
        }
        return dot / (Math.sqrt(na) * Math.sqrt(nb));
    }
}

防呆:实现手写相似度时,dot 必须是 (\sum a_i b_i);若误写成 dot += a[i] * a[i],Top-K 排序会完全失真——排错片段比「模型笨」更致命。


6. RagController

import com.example.springaiembedding.service.RagService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/rag")
public class RagController {
    @Autowired
    private RagService ragService;

    @GetMapping("/ask")
    public Map<String, String> ask(@RequestParam("question") String question) {
        String answer = ragService.answer(question);
        return Map.of("question", question, "answer", answer);
    }
}

自测 URL

http://localhost:8080/rag/ask?question=古代诗歌常用意象有哪些?

若答案 只覆盖部分内容偏题,多半不是「模型不行」四个字能糊弄过去的,下面分条说原因。


7. 为何 Demo 容易「不够准」?

原因 说明
切分策略过粗 ---- 切,单块可能仍很长,语义混杂;检索命中的是「整坨」里最接近问题的平均语义,颗粒度不对
Top-K = 2 太小 「常用意象有哪些」是 枚举型 问题,两条 chunk 可能盖不全植物/动物/景象/人文。RAG 不是只配 Top2,可以先大 K 再压缩。
线性扫全库 for 循环算相似度是 O(n),教学够用;数据一大就要 向量索引(HNSW 等),否则延迟和内存都扛不住(HNSW 含义见上文 §2.2)。
实现细节 bug 手写相似度时点积维度搞错、向量维度不一致等,会直接导致 召回错乱
仅有向量、没有词面 专有名词、生僻书名,向量检索偶尔会飘;生产常用 混合检索(BM25 + Vector) 补短板。
缺少「不知道」约束 Prompt 里应明确:上下文没有的信息 不要编;可显著减少「像那么回事」的幻觉。

8. 往生产走:RAG 相关实现补全( checklist )

下面这些和「会不会调 Chat Completion」关系不大,和 数据工程 关系更大。

  1. 向量持久化
    使用 pgvector / Milvus / Qdrant / Redis Stack 等,配合 Spring AI 的 VectorStore 抽象;避免「重启 embed 一遍」和「单机 List 存全量」。

  2. Chunk 策略
    固定 token段落/标题、或 结构化字段 切;必要时 重叠窗口(overlap) 保留上下文;长文档先 分段索引

  3. 混合检索与重排
    初召回:向量 Top 50 + 关键词 Top 50;再用 Rerank 模型 压成 Top 5 进 Prompt,命中率 往往上一台阶。

  4. 元数据与过滤
    索引里带 tenantIddocVersionpermission,检索前 先过滤 再相似度排序,企业里这是 合规 问题而不只是效果问题。

  5. 观测与评测
    记录 query、召回 id、得分、最终回答;用固定问集合做回归,否则每次改分块都像在黑盒炼丹。

  6. 异步与成本
    建索引(批量 embed)可走 异步任务;查询链路上区分 轻量模型 embed大模型 generate 的配额与超时。


9. 小结

  • RAG = 索引(Embedding + 向量存储)+ 检索(相似度/混合)+ 增强 Prompt + LLM 生成。
  • 流程图把 离线在线 分清楚,你在架构评审时也可以同样画:左边数据管道,右边查询 SLAs。
  • Demo 的价值是 跑通链路;要上生产,重点会挪到 chunk、向量库、混合检索、权限与评测

引用与延伸阅读

  • 文中 RAG 工作流程示意:见本文配图 images/rag-workflow-architecture.png(经典 Indexing / Retrieval & Generation 二分结构)。
  • Spring AISpring AI 官方文档(Embedding、VectorStoreChatClient 等)。
  • 智谱 AI:以官方开放平台说明为准,注意 Embedding 与 Chat 模型名、维度、计费 分离配置。
  • 同仓库若已有 《Spring AI / Embedding》 小册章节,可与本文 交叉阅读(余弦相似度、向量维度、批处理 embed 等)。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容