← 返回上一页

第9章: Spring AI 做 RAG:从检索到回答的整条链路

通过测试用例完整掌握 RAG 流程

📋 本章目标

本章对应 Test09_RAGTutorial,通过一系列测试用例带你从 0 到 1 搭建 一个完整的 RAG(Retrieval-Augmented Generation,检索增强生成)流程。

✨ 学完本章你将能够:

  • ✅ 理解 RAG 的核心概念和完整工作流程
  • ✅ 使用 EmbeddingModel 进行文本向量化
  • ✅ 使用余弦相似度实现相似度搜索
  • ✅ 构建一个简单的内存向量库 SimpleVectorStore
  • ✅ 实现完整的 RAG 流程:检索 → 增强 Prompt → 生成
  • ✅ 对比“有无 RAG”时 AI 回答效果的差异

🎯 前置要求

  • 已完成 Spring AI 前面所有测试章节
  • 已在应用中配置通义千问 API Key
  • 当前使用的模型支持 Embedding(如 text-embedding-v2

📚 RAG 是什么?

text
传统 AI:
用户:"公司2023年营收是多少?"
AI:"抱歉,我不知道..."  ❌

RAG:
1. 从知识库检索相关文档
2. 找到:"2023年营收10亿元"
3. AI 基于文档回答:"根据资料,2023年营收10亿元"  ✅

RAG 核心流程(6 步)

📝 Step 1:创建向量距离工具类

1.1 VectorDistanceUtils

创建文件:src/main/java/com/example/chatbot/util/VectorDistanceUtils.java

为什么要先写 VectorDistanceUtils:RAG 的“检索”本质是把 queryVector 与每个文档向量做相似度/距离计算,再按分数排序取 TopK。
公式 1:余弦相似度(Cosine Similarity)
text
cos(a, b) = (Σ(ai * bi)) / (sqrt(Σ(ai^2)) * sqrt(Σ(bi^2)))

含义:比较“方向”是否一致;越接近 1 越相似(常见范围 [-1, 1],Embedding 场景多落在 [0, 1])。
公式 2:欧氏距离(Euclidean Distance)
text
d(a, b) = sqrt( Σ( (ai - bi)^2 ) )

含义:比较“空间距离”;越小越相似(0 表示两个向量完全相同)。
代码如何对应公式:dotProduct 对应 Σ(ai*bi),norm 对应 sqrt(Σ(ai^2));欧氏距离就是先累加平方差再开方。
java
package com.example.chatbot.util;

/**
 * 向量距离计算工具类
 */
public class VectorDistanceUtils {

    /**
     * 计算余弦相似度,值越接近1越相似
     */
    public static double cosineSimilarity(float[] vector1, float[] vector2) {
        double dotProduct = 0.0;
        double norm1 = 0.0;
        double norm2 = 0.0;

        for (int i = 0; i < vector1.length; i++) {
            dotProduct += vector1[i] * vector2[i];
            norm1 += vector1[i] * vector1[i];
            norm2 += vector2[i] * vector2[i];
        }

        return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
    }

    /**
     * 计算欧氏距离,值越小越相似
     */
    public static double euclideanDistance(float[] vector1, float[] vector2) {
        double sum = 0.0;
        for (int i = 0; i < vector1.length; i++) {
            double diff = vector1[i] - vector2[i];
            sum += diff * diff;
        }
        return Math.sqrt(sum);
    }
}

1.2 编译验证

bash
mvn compile
mvn test-compile

📊 常见向量距离方法汇总

方法名称 数学公式 计算例子 (a=[1,2,3], b=[4,5,6]) 适用场景
余弦相似度 cos(a,b) = Σ(ai×bi) / (√Σ(ai²) × √Σ(bi²)) (1×4+2×5+3×6) / (√14 × √77) = 32 / (√1078) ≈ 0.974 文本相似度、RAG检索
欧氏距离 d(a,b) = √Σ(ai - bi)² √[(1-4)² + (2-5)² + (3-6)²] = √[9+9+9] = √27 ≈ 5.196 聚类分析、图像检索
曼哈顿距离 d(a,b) = Σ|ai - bi| |1-4| + |2-5| + |3-6| = 3 + 3 + 3 = 9 特征选择、城市距离
切比雪夫距离 d(a,b) = max|ai - bi| max(|1-4|, |2-5|, |3-6|) = max(3, 3, 3) = 3 最坏情况分析
点积 dot(a,b) = Σ(ai × bi) 1×4 + 2×5 + 3×6 = 4 + 10 + 18 = 32 快速计算、神经网络
皮尔逊相关系数 corr = Σ((xi-μx)(yi-μy)) / (√Σ(xi-μx)² × √Σ(yi-μy)²) μx=2, μy=5 → 计算得 1.0 (完全正相关) 时间序列、金融数据
杰卡德相似度 J(A,B) = |A∩B| / |A∪B| 二值化后计算重叠比例 二值向量、推荐系统
汉明距离 d(a,b) = Σ(ai ≠ bi) 计算不同位置的数量 二进制向量、错误检测
💡 RAG系统推荐
首选:余弦相似度 - 不受向量长度影响,关注语义方向
备选:欧氏距离 - 考虑绝对距离,需归一化处理
快速:点积 - 向量已归一化时计算最快

📝 Step 2:创建 RAG 测试类基础结构

2.1 Test09_RAGTutorial 基本结构

创建文件:src/test/java/com/example/chatbot/knowledge/Test09_RAGTutorial.java

java
package com.example.chatbot.knowledge;

import com.example.chatbot.util.VectorDistanceUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.*;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(classes = com.example.chatbot.AiChatbotApplication.class)
@DisplayName("知识点9:RAG 完整教程")
public class Test09_RAGTutorial {

    @Autowired
    private EmbeddingModel embeddingModel;

    @Autowired
    private ChatModel chatModel;

    // 简单的内存向量库
    static class SimpleVectorStore {
        private final List<VectorDocument> documents = new ArrayList<>();

        static class VectorDocument {
            String id;
            String content;
            float[] vector;
            Map<String, Object> metadata = new HashMap<>();

            VectorDocument(String id, String content, float[] vector) {
                this.id = id;
                this.content = content;
                this.vector = vector;
            }
        }

        void add(String id, String content, float[] vector) {
            documents.add(new VectorDocument(id, content, vector));
        }

        List<VectorDocument> search(float[] queryVector, int topK) {
            return documents.stream()
                .map(doc -> {
                    double similarity = VectorDistanceUtils.cosineSimilarity(
                        queryVector, doc.vector
                    );
                    doc.metadata.put("similarity", similarity);
                    return doc;
                })
                .sorted((a, b) -> Double.compare(
                    (Double) b.metadata.get("similarity"),
                    (Double) a.metadata.get("similarity")
                ))
                .limit(topK)
                .collect(Collectors.toList());
        }

        int size() {
            return documents.size();
        }
    }
}

🧪 测试 9.1:EmbeddingModel 注入

测试代码

java
/**
 * 测试 9.1:验证 EmbeddingModel 注入
 */
@Test
@DisplayName("9.1 EmbeddingModel 注入")
public void testEmbeddingModelInjection() {
    System.out.println("\n========== 测试:EmbeddingModel 注入 ==========");

    assertNotNull(embeddingModel, "EmbeddingModel 应该被注入");

    System.out.println("✅ EmbeddingModel 注入成功");
    System.out.println("📝 类型: " + embeddingModel.getClass().getSimpleName());
    System.out.println("✅ 测试通过");
}

运行命令

bash
mvn test -Dtest=Test09_RAGTutorial#testEmbeddingModelInjection

🧪 测试 9.2:文本向量化

测试代码

java
/**
 * 测试 9.2:文本向量化
 */
@Test
@DisplayName("9.2 文本向量化")
public void testEmbedding() {
    System.out.println("\n=== 测试向量化 ===");

    String text = "Spring AI 是一个强大的框架";
    System.out.println("📝 原始文本: " + text);

    System.out.println("\n🔄 向量化中...");
    var response = embeddingModel.embedForResponse(List.of(text));
    float[] vector = response.getResults().get(0).getOutput();

    System.out.println("✅ 向量化完成");
    System.out.println("📊 向量维度: " + vector.length);
    System.out.println("📊 前5个值: " + java.util.Arrays.toString(
        java.util.Arrays.copyOfRange(vector, 0, Math.min(5, vector.length))
    ));

    assertTrue(vector.length > 0, "向量不应为空");
    assertEquals(1536, vector.length, "通义千问的向量维度应该是1536");

    System.out.println("✅ 向量化测试通过");
}

运行命令

bash
mvn test -Dtest=Test09_RAGTutorial#testEmbedding

🧪 测试 9.3:相似度计算

测试代码

java
/**
 * 测试 9.3:相似度计算
 */
@Test
@DisplayName("9.3 相似度计算")
public void testSimilarity() {
    System.out.println("\n=== 测试相似度计算 ===");

    String text1 = "我喜欢编程";
    String text2 = "我热爱写代码";
    String text3 = "今天天气真好";

    System.out.println("📝 文本1: " + text1);
    System.out.println("📝 文本2: " + text2);
    System.out.println("📝 文本3: " + text3);

    System.out.println("\n🔄 向量化中...");
    java.util.List<float[]> vectors = embeddingModel.embed(java.util.List.of(text1, text2, text3));

    System.out.println("\n📊 相似度计算:");
    double sim12 = VectorDistanceUtils.cosineSimilarity(vectors.get(0), vectors.get(1));
    double sim13 = VectorDistanceUtils.cosineSimilarity(vectors.get(0), vectors.get(2));
    double sim23 = VectorDistanceUtils.cosineSimilarity(vectors.get(1), vectors.get(2));

    System.out.println("  文本1 vs 文本2: " + String.format("%.4f", sim12));
    System.out.println("  文本1 vs 文本3: " + String.format("%.4f", sim13));
    System.out.println("  文本2 vs 文本3: " + String.format("%.4f", sim23));

    System.out.println("\n💡 分析:");
    assertTrue(sim12 > sim13, "相似的文本(1和2)相似度应该更高");
    assertTrue(sim12 > sim23, "相似的文本(1和2)相似度应该更高");
    System.out.println("  ✅ 编程相关的文本确实更相似");

    System.out.println("✅ 相似度测试通过");
}

🧪 测试 9.4:构建简单向量库

测试代码

java
/**
 * 测试 9.4:构建简单向量库
 */
@Test
@DisplayName("9.4 构建简单向量库")
public void testVectorStore() {
    System.out.println("\n=== 测试向量库 ===");

    java.util.List<String> documents = java.util.List.of(
        "Spring AI 是 Spring 官方的 AI 集成框架",
        "Spring Boot 简化了 Spring 应用的开发",
        "Java 是一种面向对象的编程语言",
        "Python 在数据科学领域很流行",
        "RAG 是检索增强生成技术"
    );

    System.out.println("📚 准备知识库文档...");
    documents.forEach(doc -> System.out.println("  - " + doc));

    System.out.println("\n🔄 向量化文档...");
    java.util.List<float[]> vectors = embeddingModel.embed(documents);
    System.out.println("✅ 向量化完成,共 " + vectors.size() + " 个向量");

    System.out.println("\n💾 存入向量库...");
    SimpleVectorStore vectorStore = new SimpleVectorStore();
    for (int i = 0; i < documents.size(); i++) {
        vectorStore.add("doc-" + i, documents.get(i), vectors.get(i));
    }
    System.out.println("✅ 存储完成,向量库大小: " + vectorStore.size());

    System.out.println("\n🔍 测试检索...");
    String query = "什么是 Spring AI?";
    System.out.println("  查询: " + query);

    float[] queryVector = embeddingModel.embed(query);
    java.util.List<SimpleVectorStore.VectorDocument> results = vectorStore.search(queryVector, 3);

    System.out.println("\n📊 検索结果(Top 3):");
    for (int i = 0; i < results.size(); i++) {
        var doc = results.get(i);
        System.out.println("  " + (i + 1) + ". [相似度: " +
            String.format("%.4f", doc.metadata.get("similarity")) + "] " +
            doc.content);
    }

    assertTrue(results.get(0).content.contains("Spring AI"),
        "最相关的文档应该包含 Spring AI");

    System.out.println("\n✅ 向量库测试通过");
}

🧪 测试 9.5:完整 RAG 流程

测试代码

java
/**
 * 测试 9.5:完整 RAG 流程
 */
@Test
@DisplayName("9.5 完整 RAG 流程")
public void testCompleteRAG() {
    System.out.println("\n=== 完整 RAG 流程 ===");

    System.out.println("【步骤1:构建知识库】");
    java.util.List<String> knowledge = java.util.List.of(
        "公司成立于2020年,总部位于北京",
        "2023年公司营收达到10亿元,同比增长50%",
        "公司主要产品是AI助手,服务超过100万用户",
        "公司有员工500人,其中研发人员占60%"
    );
    knowledge.forEach(doc -> System.out.println("  📄 " + doc));

    System.out.println("\n【步骤2:向量化并存储】");
    SimpleVectorStore vectorStore = new SimpleVectorStore();
    java.util.List<float[]> vectors = embeddingModel.embed(knowledge);
    for (int i = 0; i < knowledge.size(); i++) {
        vectorStore.add("doc-" + i, knowledge.get(i), vectors.get(i));
    }
    System.out.println("  ✅ 存储了 " + vectorStore.size() + " 个文档");

    System.out.println("\n【步骤3:用户提问】");
    String question = "公司2023年的营收是多少?";
    System.out.println("  👤 问题: " + question);

    System.out.println("\n【步骤4:检索相关文档】");
    float[] questionVector = embeddingModel.embed(question);
    java.util.List<SimpleVectorStore.VectorDocument> relevantDocs =
        vectorStore.search(questionVector, 2);
    System.out.println("  🔍 找到 " + relevantDocs.size() + " 个相关文档:");
    relevantDocs.forEach(doc ->
        System.out.println("    - " + doc.content)
    );

    System.out.println("\n【步骤5:构造增强 Prompt】");
    String context = relevantDocs.stream()
        .map(doc -> doc.content)
        .collect(java.util.stream.Collectors.joining("\n"));
    String enhancedPrompt = String.format(
        "基于以下信息回答问题:\n\n%s\n\n问题:%s",
        context, question
    );
    System.out.println("  📝 增强后的 Prompt:\n  " + enhancedPrompt.replace("\n", "\n  "));

    System.out.println("\n【步骤6:AI 生成回答】");
    var response = chatModel.call(new org.springframework.ai.chat.prompt.Prompt(enhancedPrompt));
    String answer = response.getResult().getOutput().getText();
    System.out.println("  🤖 AI 回答: " + answer);

    assertTrue(answer.contains("10亿") || answer.contains("10 亿"),
        "回答应该包含营收数据");

    System.out.println("\n✅ 完整 RAG 流程测试通过");
}

🧪 测试 9.6:对比有无 RAG

测试代码

java
/**
 * 测试 9.6:对比有无 RAG
 */
@Test
@DisplayName("9.6 对比有无 RAG")
public void testWithAndWithoutRAG() {
    System.out.println("\n=== 对比有无 RAG ===");

    java.util.List<String> knowledge = java.util.List.of(
        "XYZ 公司成立于2020年",
        "XYZ 公司2023年营收10亿元"
    );

    SimpleVectorStore vectorStore = new SimpleVectorStore();
    java.util.List<float[]> vectors = embeddingModel.embed(knowledge);
    for (int i = 0; i < knowledge.size(); i++) {
        vectorStore.add("doc-" + i, knowledge.get(i), vectors.get(i));
    }

    String question = "XYZ 公司2023年营收多少?";

    System.out.println("\n【测试1:没有 RAG】");
    System.out.println("👤 问题: " + question);
    var response1 = chatModel.call(new org.springframework.ai.chat.prompt.Prompt(question));
    String answer1 = response1.getResult().getOutput().getText();
    System.out.println("🤖 回答: " + answer1);
    System.out.println("❌ AI 可能不知道答案");

    System.out.println("\n【测试2:使用 RAG】");
    float[] qVector = embeddingModel.embed(question);
    java.util.List<SimpleVectorStore.VectorDocument> docs = vectorStore.search(qVector, 1);
    String context = docs.get(0).content;
    String enhancedPrompt = String.format(
        "根据以下信息回答:%s\n\n问题:%s", context, question
    );
    var response2 = chatModel.call(new org.springframework.ai.chat.prompt.Prompt(enhancedPrompt));
    String answer2 = response2.getResult().getOutput().getText();
    System.out.println("🤖 回答: " + answer2);
    System.out.println("✅ AI 基于知识库准确回答");

    assertTrue(answer2.contains("10亿") || answer2.contains("10 亿"));

    System.out.println("\n✅ 对比测试通过");
}

🧪 测试 9.7(可选):接入 ChromaDB 向量库

真实项目中通常会把向量存到向量数据库(便于持久化、并发、检索性能与规模扩展)。 当前这套项目里已验证可跑通的组合是:chromadb==0.4.24 + spring-ai-chroma-store-spring-boot-starter:1.0.0-M6, 本地服务监听 localhost:8000,并通过 ChromaDB 的 v1 API 正常读写集合。

步骤1:启动 ChromaDB

📋 安装方式选择
方式一:本地安装(推荐,当前已验证) - 使用 chromadb==0.4.24
bash
pip install chromadb==0.4.24

# 启动本地 ChromaDB 服务
chroma run --host localhost --port 8000
方式二:Docker(可选) - 镜像版本请与本地版本保持一致(0.4.24)
🔧 本地安装全流程
步骤 1.1:检查 Python 环境
bash
# 检查 Python 版本(需要 3.7+)
python --version

# 如果没有 Python,先安装 Python 3.7+
# macOS: brew install python
# Ubuntu: sudo apt install python3 python3-pip
# Windows: 从 python.org 下载安装
步骤 1.2:安装 ChromaDB
bash
# 当前已验证版本:0.4.24
pip install chromadb==0.4.24

# 验证安装和版本
python -c "import chromadb; print(f'ChromaDB 版本: {chromadb.__version__}')"
步骤 1.3:启动 ChromaDB 服务
bash
# 启动本地 ChromaDB 服务
chroma run --host localhost --port 8000

# 验证服务状态
curl http://localhost:8000/api/v1/heartbeat
步骤 1.4:验证服务状态
bash
# 检查服务是否运行(v1 API)
curl http://localhost:8000/api/v1/heartbeat

# 预期返回:{"nanosecond heartbeat": 1234567890}

# 或者使用 Python 验证
python -c "
import chromadb
try:
    client = chromadb.HttpClient(host='localhost', port=8000)
    print('✅ ChromaDB v1 API 连接成功!')
    print(f'集合数量: {len(client.list_collections())}')
except Exception as e:
    print(f'❌ 连接失败: {e}')
"
步骤 1.5:常用管理命令
bash
# 查看进程
ps aux | grep chroma

# 停止服务
pkill -f chroma

# 查看日志
tail -f chroma.log

# 重启服务
pkill -f chroma && chroma run --host localhost --port 8000
⚠️ 注意事项
端口占用:确保 8000 端口未被占用
防火墙:如需远程访问,开放 8000 端口
数据持久化:默认数据存储在内存,重启会丢失
生产环境:建议使用 Docker 或配置数据持久化路径

步骤2:引入依赖(Spring Boot Starter)

xml
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-chroma-store-spring-boot-starter</artifactId>
</dependency>

步骤3:配置 application.yml(示例)

⚠️ 当前已验证配置说明
当前项目实际可运行的组合是:
• ChromaDB:0.4.24
• Spring AI:1.0.0-M6
• 连接地址:http://localhost:8000
• API 版本:v1
如果升级到 ChromaDB 1.x,可能会出现 The v1 API is deprecated 的兼容性问题。
yaml
spring:
  ai:
    vectorstore:
      chroma:
        client:
          host: http://localhost
          port: 8000
        collection-name: SpringAiCollection
        initialize-schema: true

步骤4:依赖版本兼容性

xml
<!-- Spring AI ChromaDB Store -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-chroma-store-spring-boot-starter</artifactId>
  <version>1.0.0-M6</version>
</dependency>

<!-- 当前已验证:ChromaDB 0.4.24 + Spring AI 1.0.0-M6 -->

步骤5:测试写入 + 检索

java
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;

@Autowired
private VectorStore vectorStore;

@Test
@DisplayName("9.7 ChromaDB 向量库(可选)")
public void testChromaDbVectorStore() {
    System.out.println("\n=== 测试 9.7:ChromaDB VectorStore ===");

    java.util.List<Document> docs = java.util.List.of(
        new Document("Spring AI rocks!!"),
        new Document("RAG = 检索 + 增强 Prompt + 生成"),
        new Document("向量数据库用于存储 embedding 并做相似度检索")
    );

    vectorStore.add(docs);

    java.util.List<Document> results = vectorStore.similaritySearch(
        SearchRequest.builder().query("Spring AI").topK(2).build()
    );

    System.out.println("🔍 TopK 结果数量: " + results.size());
    results.forEach(d -> System.out.println("  - " + d.getText()));
}

// 运行
// mvn test -Dtest=Test09_RAGTutorial#testChromaDbVectorStore
提示:这里的 VectorStore 会在引入 starter 后自动装配; 如果你要接入 Chroma Cloud / 鉴权,再按官方文档改成手动创建 ChromaApiChromaVectorStore Bean。

步骤6:故障排除

🚨 常见错误及解决方案
错误1:升级到 ChromaDB 1.x 后出现 v1 API 弃用
410 Gone: "{"error":"Unimplemented","message":"The v1 API is deprecated. Please use /v2 apis"}"
说明:当前这套项目已验证的可运行配置是 chromadb==0.4.24, 所以只要保持版本不升级到 1.x,一般不会遇到这个错误;如果你升级了 ChromaDB,则需要重新处理 API 兼容。
解决方案:
• 使用 ChromaDB 0.4.24 版本:pip install chromadb==0.4.24
• 服务启动命令:chroma run --host localhost --port 8000
• 保持 Spring AI 的 Chroma 配置为 spring.ai.vectorstore.chroma
错误2:连接超时
解决方案:
• 检查 ChromaDB 是否启动:curl http://localhost:8000/api/v1/heartbeat
• 检查端口是否被占用:netstat -tulpn | grep 8000
• 检查防火墙设置
错误3:Bean创建失败
解决方案:
• 检查依赖版本兼容性
• 确保 application.yml 配置正确
• 添加 @TestConfiguration 或排除自动配置
💡 推荐做法
学习环境:使用 ChromaDB 0.4.24 版本,避免兼容性问题
生产环境:考虑使用 Pinecone、Weaviate 或 Qdrant
测试优先:先验证 ChromaDB 服务可用性,再集成 Spring AI

🚀 运行所有 RAG 测试

运行完整的 RAG 教程测试类:

bash
mvn test -Dtest=Test09_RAGTutorial

预期输出

text
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

✅ 所有测试通过!

📚 课后习题与常见面试题

请完成下面练习,巩固 RAG 的“向量检索 + Prompt 增强 + 生成”闭环能力

⚠️ 提示:本章高频点集中在 Embedding、相似度/TopK 检索、Chunk/Overlap、检索结果组织成 Prompt、以及“幻觉/可解释性”。

🎯 课后习题(动手为主)

1) 写一个“TopK 检索”测试:验证 query 最相似的文档排在第一
要求:构造 3 条知识文本,向量化后入库;query 选择与其中 1 条最接近的问题;断言 Top1 命中文档内容。 建议:打印 similarity 方便定位检索效果。
参考答案(展开/收起)
java
java.util.List<String> knowledge = java.util.List.of(
    "XYZ 公司成立于2020年",
    "XYZ 公司2023年营收10亿元",
    "XYZ 公司总部位于杭州"
);

SimpleVectorStore store = new SimpleVectorStore();
java.util.List<float[]> vectors = embeddingModel.embed(knowledge);
for (int i = 0; i < knowledge.size(); i++) {
    store.add("doc-" + i, knowledge.get(i), vectors.get(i));
}

String query = "XYZ 公司 2023 年的营收是多少?";
float[] q = embeddingModel.embed(query);
java.util.List<SimpleVectorStore.VectorDocument> top = store.search(q, 1);

String top1 = top.get(0).content;
System.out.println("Top1=" + top1);
System.out.println("similarity=" + top.get(0).metadata.get("similarity"));

assertTrue(top1.contains("营收10亿") || top1.contains("营收10 亿元"));
要点:你要验证的是“检索排序正确”,而不是模型回答是否正确;所以断言聚焦在 TopK 文档内容。
2) 设计一个“Chunk + Overlap”分割策略:解释为什么 overlap 能提升召回
要求:写一个简单的 split 方法,按 chunkSize 切分,并保留 overlapSize 重叠;用一个包含“跨边界关键词”的示例解释为什么 overlap 有用。
参考答案(展开/收起)
java
static java.util.List<String> split(String text, int chunkSize, int overlapSize) {
    java.util.List<String> chunks = new java.util.ArrayList<>();
    int start = 0;
    while (start < text.length()) {
        int end = Math.min(text.length(), start + chunkSize);
        chunks.add(text.substring(start, end));
        if (end == text.length()) break;
        start = Math.max(0, end - overlapSize);
    }
    return chunks;
}
解释:当关键信息跨越 chunk 边界(例如“答案”出现在上一块末尾、问题关键词出现在下一块开头),无 overlap 时容易被拆散导致召回下降;有 overlap 时至少有一块能完整覆盖上下文,提高检索命中率。
3) 写一个“增强 Prompt 模板”:把 TopK 文档组织成可控格式,并要求模型引用来源
要求:增强 Prompt 里包含:系统指令(只基于资料回答/不知道就说不知道)、资料列表(带编号)、用户问题。
参考答案(展开/收起)
java
String context = java.util.stream.IntStream.range(0, relevantDocs.size())
    .mapToObj(i -> "[" + (i + 1) + "] " + relevantDocs.get(i).content)
    .collect(java.util.stream.Collectors.joining("\n"));

String promptText = "你是一个严谨的知识库问答助手。\n" +
    "- 只能根据【资料】回答问题\n" +
    "- 如果资料不足以回答,请直接说:资料不足,无法回答\n" +
    "- 回答时请标注引用的资料编号,例如:[1][2]\n\n" +
    "【资料】\n" + context + "\n\n" +
    "【问题】\n" + question;

var resp = chatModel.call(new org.springframework.ai.chat.prompt.Prompt(promptText));
String answer = resp.getResult().getOutput().getText();
System.out.println(answer);
要点:把“只基于资料”写进 Prompt,是减少幻觉的关键工程手段;编号引用能提升可解释性。
4) (可选)用 ChromaDB 做一次真正的 VectorStore:add + similaritySearch
要求:启动 ChromaDB,配置 spring-ai-chroma-store-spring-boot-starter,用 VectorStore 写入 3 条文档并检索 TopK。
参考答案(展开/收起)
bash
docker run -it --rm --name chroma -p 8000:8000 ghcr.io/chroma-core/chroma:1.0.0
java
java.util.List<org.springframework.ai.document.Document> docs = java.util.List.of(
    new org.springframework.ai.document.Document("Spring AI rocks!!"),
    new org.springframework.ai.document.Document("RAG = 检索 + 增强 Prompt + 生成"),
    new org.springframework.ai.document.Document("向量数据库用于存储 embedding 并做相似度检索")
);
vectorStore.add(docs);

java.util.List<org.springframework.ai.document.Document> results = vectorStore.similaritySearch(
    org.springframework.ai.vectorstore.SearchRequest.builder().query("Spring AI").topK(2).build()
);
results.forEach(d -> System.out.println(d.getText()));

💡 常见面试题(高频必会)

1) 什么是 RAG?它解决了大模型的什么问题?
参考答案(展开/收起)
定义:RAG(Retrieval-Augmented Generation)= 先检索(向量检索/关键词检索)拿到与问题相关的外部资料,再把资料作为上下文增强 Prompt,让模型基于资料生成。
主要解决:
1) 知识过期/缺失:模型参数内的知识固定;RAG 能接入最新资料。
2) 幻觉:要求模型“只基于检索到的资料回答”,能显著减少编造。
3) 可解释性:可返回命中文档与相似度,解释“为什么这样答”。
对比微调:RAG 更轻量、更新成本低;微调更适合风格/格式/特定能力固化。
2) EmbeddingModel 和 ChatModel 有什么区别?
参考答案(展开/收起)
EmbeddingModel:把文本映射为向量,用于相似度检索/聚类/去重(输入=文本,输出=向量)。
ChatModel:基于 Prompt 生成自然语言回复(输入=Prompt,输出=文本)。
在 RAG 中:Embedding 负责“检索”,Chat 负责“生成”。
3) 余弦相似度和欧氏距离的区别?检索时怎么选?
参考答案(展开/收起)
余弦相似度:更关注“方向”,对向量长度不敏感;常用于文本 embedding 检索。
欧氏距离:更关注“空间距离”,会受向量尺度影响。
经验选择:大多数通用文本 embedding 直接用余弦相似度;若模型/向量库内部已做归一化,也可以统一使用 cosine。
4) TopK、chunkSize、overlap 怎么调?各自的 trade-off 是什么?
参考答案(展开/收起)
TopK:越大召回越高,但 Prompt 更长、噪声更多、成本更高;一般从 3~8 起步。
chunkSize:越大上下文更完整,但检索粒度更粗;越小粒度更细但容易丢上下文。
overlap:用来防止关键信息被切碎;overlap 越大冗余越高,入库量增加、成本上升。
建议:以“回答质量 + 成本”双指标迭代,结合真实问题做 A/B。
5) RAG 如何做“可解释性”和“可观测性”?
参考答案(展开/收起)
可解释性:返回 TopK 文档片段 + 相似度 + 元数据(文件名/页码/段落),并在答案中标注引用编号。
可观测性:记录 topK、chunkSize、overlap、命中文档、相似度分布、Prompt token、回答 token、耗时;出现错答时可快速回放与定位是“检索错”还是“生成错”。
6) RAG 的安全风险有哪些?如何防 prompt injection?
参考答案(展开/收起)
主要风险:
1) Prompt Injection:知识库文本里带“忽略指令/泄露密钥”等恶意内容。
2) 越权检索:不同租户/用户检索到不该看的文档。
3) 隐私泄露:把敏感信息拼进 Prompt 或返回给用户。
应对:
1) 系统指令硬约束:明确“资料仅作为参考,不能覆盖系统指令”。
2) 检索权限过滤:metadata + ACL;按用户/租户过滤后再 TopK。
3) 内容清洗与安全扫描:入库前过滤敏感词/指令注入模式。
4) 最小化上下文:只传必要片段,避免整库灌入 Prompt。

📝 本章小结

🎓 你已经掌握:

  • RAG 核心概念:Embedding、相似度、向量库、增强生成
  • RAG 流程实践:构建知识库 → 检索 → 增强 Prompt → 生成
  • 测试驱动实现:通过 6 个测试用例逐步搭建 RAG
  • 效果对比:清楚看到“有无 RAG”时 AI 回答能力的差异
← 上一章 下一章 →