RAG 实战(二):跑通最简 RAG 链路

目录


一、这个 Demo 要做什么

目标很单纯:不引入任何优化,先把链路跑通。

优化是后面几篇的事。这一篇只做一件事——让「上传一份文档,然后向它提问,拿到一个带出处的回答」这条路径真正跑起来。代码量约 300 行,没有 Rerank,没有混合检索,没有评估,一个检索器走到底。

graph TB
    U["用户提问<br/>「公司的年假政策是什么?」"] --> QE["问题向量化"]
    QE --> R["从向量库检索 Top-K 相关片段"]
    R --> P["片段 + 问题 组装成 Prompt"]
    P --> L["调用大模型"]
    L --> A["输出:<br/>「根据 [1],入职满 1 年年假 10 天…」"]

    style R fill:#e6f3ff

跑通之后,你应该能回答四个问题:RAG 的完整链路是什么、文档怎么切、Embedding 是什么、向量检索怎么工作。这四个问题在后面的每一层优化里都会被重新追问一遍,所以第一篇别急着往前赶。


二、四个环节的原理

2.1 文档切分

想象你有一本 500 页的《员工手册》,同事问「年假怎么算」。你不可能把整本书塞给模型——太长,而且绝大部分内容和问题无关。所以要先撕成小块:

graph LR
    A["原始文档<br/>一本完整的书"] --> B["按 500 字符切分<br/>重叠 50 字符"]
    B --> C1["Chunk 1<br/>第 1–500 字"]
    B --> C2["Chunk 2<br/>第 451–950 字"]
    B --> C3["Chunk 3<br/>第 901–1400 字"]
    C1 --> D[("入库<br/>每块一个唯一 ID")]
    C2 --> D
    C3 --> D

    style D fill:#e6f3ff

为什么要重叠? 因为按固定长度硬切,切口正好落在一句话中间时,两边都会变成残句:

没有重叠:
Chunk 1: "...员工入职满一年后可享受"
Chunk 2: "年假10天,满五年享受15天..."
→ 检索到 Chunk 2 时,模型不知道为什么会有「满五年」

有重叠:
Chunk 1: "...员工入职满一年后可享受年假10天"
Chunk 2: "可享受年假10天,满五年享受15天..."
→ 两块都能独立读懂

重叠解决的是「切口落在语义边界之外」的问题。但注意它只是缓解,不是根治——真正的解法是按结构切(按标题、段落边界),这个我们放到第六篇再谈。

2.2 Embedding

Embedding 就是把一段文字变成一串数字。这串数字代表这段话的「意思」:

"年假政策" → [0.12, -0.34, 0.56,  0.78, -0.90, ...]
"休假制度" → [0.11, -0.33, 0.55,  0.77, -0.89, ...]
"报销流程" → [-0.45, 0.67, -0.12, 0.89, 0.23, ...]

关键特性是:意思相近的文字,向量也相近。 前两个向量几乎重合,第三个和它们差得很远。这不是巧合,而是模型训练时刻意塑造的——它把语义空间压缩成了一个「距离即相似度」的几何空间。

这恰好弥补了关键词搜索的短板:

用户搜:"年假"
文档写的是:"休假天数"
→ 关键词一个字都对不上,传统检索失效

用 Embedding:
"年假" 的向量 ≈ "休假天数" 的向量
→ 语义相近,能召回

但反过来,Embedding 对精确字符串是不敏感的——搜「GPT-4」它可能给你「大模型」。这句话先记住,第三篇讲混合检索时会回到这里。

2.3 向量检索

入库时每个 chunk 都存了向量。用户提问时把问题也向量化,然后逐个算相似度,取最像的 Top-K:

用户问题向量: [0.12, -0.34, 0.56, ...]
        ↓ 和每个文档向量计算余弦距离
Chunk 1: [0.11, -0.33, 0.55, ...] → 距离 0.05  ✓ 最相关
Chunk 2: [-0.45, 0.67, -0.12, ...] → 距离 0.77
Chunk 3: [0.13, -0.35, 0.57, ...] → 距离 0.08  ✓ 相关
        ↓ 返回 Top 5

这里有个容易搞反的地方:pgvector 的 <=> 返回的是距离,不是相似度。 距离越小越像,所以 SQL 里要 ORDER BY distance ASC。写成 DESC 会得到一堆最不相关的结果,而且不会报错——它看起来「能跑」,只是答案很离谱。这个坑后面代码里会再标注一次。

2.4 Prompt 组装

最后一步是把检索到的片段和问题拼成一段文本交给模型。模板大致长这样:

## 参考资料:
[1] {chunk1 的内容}
[2] {chunk2 的内容}
[3] {chunk3 的内容}

## 用户问题:
{用户的问题}

## 请回答:

配合一条 system 指令,规定三件事:只依据参考资料回答、资料不足时明确说明、引用时用 [1] 标注来源。

这段模板是整套 RAG 里最便宜、也最容易被忽略的一环。 很多「模型胡说」的问题,根子不在检索,而在于 Prompt 里没写「资料没有就说不知道」——模型默认会倾向于给一个像样的答案,而不是老实承认资料里没有。


三、完整代码

3.1 项目配置

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.0</version>
    </parent>

    <groupId>com.rag</groupId>
    <artifactId>demo1-basic-rag</artifactId>
    <version>1.0.0</version>
    <name>Demo1 - Basic RAG</name>
    <description>最简 RAG 实现</description>

    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M4</spring-ai.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring AI 核心 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-core</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>

        <!-- Spring AI OpenAI 集成 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>

        <!-- PostgreSQL JDBC -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Spring JDBC -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <!-- Lombok(可选,简化代码) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- JSON 处理 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

application.yml

server:
  port: 8080

spring:
  # 数据库配置
  datasource:
    url: jdbc:postgresql://localhost:5432/rag_db
    username: postgres
    password: postgres

  # Spring AI 配置
  ai:
    openai:
      api-key: ${OPENAI_API_KEY:sk-xxx}  # 替换成你的 API Key
      base-url: ${OPENAI_BASE_URL:https://api.openai.com}  # 如果用代理,改成代理地址
      embedding:
        options:
          model: text-embedding-3-small  # Embedding 模型
      chat:
        options:
          model: gpt-4o  # 或 gpt-3.5-turbo(更便宜)

# 自定义配置
rag:
  chunk-size: 500        # 每个 Chunk 的字符数
  chunk-overlap: 50      # Chunk 之间的重叠字符数
  top-k: 5               # 检索返回的文档数量

rag.chunk-size 和 rag.chunk-overlap 都是可配置项,默认 500 / 50。调这两个值不需要改代码,这意味着你可以拿同一批文档跑不同的切分策略做对比——第五篇做评估时这一点很有用。

3.2 数据库初始化

init.sql

-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 创建文档表
CREATE TABLE IF NOT EXISTS documents (
    id BIGSERIAL PRIMARY KEY,              -- 主键
    content TEXT NOT NULL,                  -- 文档内容(切分后的 Chunk)
    metadata JSONB DEFAULT '{}',           -- 元数据(来源、页码等)
    embedding vector(1536),                -- 向量(1536 维,对应 text-embedding-3-small)
    created_at TIMESTAMP DEFAULT NOW()     -- 创建时间
);

-- 创建向量索引(提高检索速度)
-- IVFFlat 索引适合数据量 < 100万 的场景
CREATE INDEX IF NOT EXISTS idx_documents_embedding
ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- 创建元数据索引
CREATE INDEX IF NOT EXISTS idx_documents_metadata
ON documents USING gin (metadata);

建表语句里有两个决定值得说明:

  • embedding vector(1536):1536 是 text-embedding-3-small 的输出维度,写死在这里意味着换 Embedding 模型就得改表结构、重新入库。第一篇提过这一点,这里就是它具体的样子。
  • 索引用的是 IVFFlat,lists = 100。IVFFlat 属于「聚类索引」,它需要数据存在之后才能有效划分簇——在一张空表上建这个索引,等于建了个没有意义的壳。数据量小的时候(几千条以内)走顺序扫描反而更准,PostgreSQL 的执行计划器也可能直接忽略这个索引。这个索引更适合灌完数据之后再建。

3.3 核心代码

启动类:RagDemo1Application.java

package com.rag;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * RAG Demo 1 启动类
 *
 * 这个 Demo 实现了最基础的 RAG 流程:
 * 1. 文档切分 → 把长文档切成小块
 * 2. 向量化 → 把文字变成数字(Embedding)
 * 3. 存储 → 存入 PostgreSQL + pgvector
 * 4. 检索 → 根据用户问题找相关文档
 * 5. 生成 → 把文档和问题一起发给大模型
 */
@SpringBootApplication
public class RagDemo1Application {

    public static void main(String[] args) {
        SpringApplication.run(RagDemo1Application.class, args);
    }
}

文档实体:Document.java

package com.rag.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Map;

/**
 * 文档实体类
 *
 * 类比:图书馆里的一张卡片
 * - id: 卡片编号
 * - content: 卡片上的内容(切分后的文档片段)
 * - metadata: 附加信息(来自哪本书、第几页)
 * - embedding: 这段内容的"语义指纹"
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Document {

    /**
     * 文档 ID
     */
    private Long id;

    /**
     * 文档内容(切分后的 Chunk)
     *
     * 例如:"员工入职满一年后,可享受带薪年假10天。入职满五年后,年假增至15天。"
     */
    private String content;

    /**
     * 元数据
     *
     * 存储文档的附加信息,如:
     * - source: 来源文件名
     * - page: 页码
     * - chunk_index: 这是第几个切片
     */
    private Map<String, Object> metadata;

    /**
     * Embedding 向量
     *
     * 这是 content 经过 Embedding 模型转换后的数字表示
     * text-embedding-3-small 模型生成 1536 维的向量
     */
    private float[] embedding;

    /**
     * 创建时间
     */
    private LocalDateTime createdAt;
}

配置类:AiConfig.java

package com.rag.config;

import org.springframework.context.annotation.Configuration;

/**
 * AI 配置类
 *
 * 目前使用 application.yml 中的配置即可
 * 后续可以在这里添加自定义的 Bean
 */
@Configuration
public class AiConfig {

    // 后续会在这里添加自定义配置
}

文档处理服务:DocumentService.java

package com.rag.service;

import com.rag.model.Document;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;

/**
 * 文档处理服务
 *
 * 职责:
 * 1. 接收原始文档
 * 2. 切分成小块(Chunk)
 * 3. 返回切分后的文档列表
 *
 * 类比:图书馆的分拣员
 * - 收到一整本书
 * - 撕成一页一页的
 * - 给每页编号
 */
@Slf4j
@Service
public class DocumentService {

    @Value("${rag.chunk-size:500}")
    private int chunkSize;

    @Value("${rag.chunk-overlap:50}")
    private int chunkOverlap;

    /**
     * 将文档切分成小块
     *
     * @param content 原始文档内容
     * @param source  文档来源(文件名)
     * @return 切分后的文档列表
     */
    public List<Document> splitDocument(String content, String source) {
        log.info("开始切分文档,来源: {}, 长度: {} 字符", source, content.length());

        List<Document> chunks = new ArrayList<>();

        // 如果文档很短,不需要切分
        if (content.length() <= chunkSize) {
            Document doc = Document.builder()
                    .content(content)
                    .metadata(Map.of(
                            "source", source,
                            "chunk_index", 0,
                            "total_chunks", 1
                    ))
                    .build();
            chunks.add(doc);
            return chunks;
        }

        // 按固定大小切分,带重叠
        int startIndex = 0;
        int chunkIndex = 0;

        while (startIndex < content.length()) {
            // 计算结束位置
            int endIndex = Math.min(startIndex + chunkSize, content.length());

            // 提取 Chunk 内容
            String chunkContent = content.substring(startIndex, endIndex);

            // 创建文档对象
            Document doc = Document.builder()
                    .content(chunkContent)
                    .metadata(Map.of(
                            "source", source,
                            "chunk_index", chunkIndex,
                            "start_pos", startIndex,
                            "end_pos", endIndex
                    ))
                    .build();

            chunks.add(doc);

            // 移动到下一个位置(注意重叠)
            startIndex += chunkSize - chunkOverlap;
            chunkIndex++;
        }

        log.info("文档切分完成,共 {} 个 Chunk", chunks.size());

        // 更新 total_chunks 元数据
        for (Document chunk : chunks) {
            chunk.getMetadata().put("total_chunks", chunks.size());
        }

        return chunks;
    }

    /**
     * 按段落切分(更智能的切分方式)
     *
     * 优先按段落切分,如果段落太长再按句子切分
     *
     * @param content 原始文档内容
     * @param source  文档来源
     * @return 切分后的文档列表
     */
    public List<Document> splitDocumentByParagraph(String content, String source) {
        log.info("使用段落切分策略,来源: {}", source);

        List<Document> chunks = new ArrayList<>();

        // 先按段落分割
        String[] paragraphs = content.split("\n\n+");

        StringBuilder currentChunk = new StringBuilder();
        int chunkIndex = 0;

        for (String paragraph : paragraphs) {
            paragraph = paragraph.trim();
            if (paragraph.isEmpty()) {
                continue;
            }

            // 如果当前 Chunk 加上新段落超过限制,先保存当前 Chunk
            if (currentChunk.length() + paragraph.length() > chunkSize && currentChunk.length() > 0) {
                Document doc = Document.builder()
                        .content(currentChunk.toString().trim())
                        .metadata(Map.of(
                                "source", source,
                                "chunk_index", chunkIndex++,
                                "split_strategy", "paragraph"
                        ))
                        .build();
                chunks.add(doc);
                currentChunk = new StringBuilder();
            }

            // 添加段落到当前 Chunk
            if (currentChunk.length() > 0) {
                currentChunk.append("\n\n");
            }
            currentChunk.append(paragraph);
        }

        // 处理最后一个 Chunk
        if (currentChunk.length() > 0) {
            Document doc = Document.builder()
                    .content(currentChunk.toString().trim())
                    .metadata(Map.of(
                            "source", source,
                            "chunk_index", chunkIndex,
                            "split_strategy", "paragraph"
                    ))
                    .build();
            chunks.add(doc);
        }

        log.info("段落切分完成,共 {} 个 Chunk", chunks.size());
        return chunks;
    }
}

这个类提供两种切分方式:splitDocument 按固定长度切(就是 2.1 讲的策略),splitDocumentByParagraph 按空行分段切。两者的排序逻辑是一致的——每个 chunk 都在 metadata 里记下了 source、chunk_index 和起止位置,这样回答时才能回溯到原文出处,也方便后面做增量更新和删除。

Embedding 服务:EmbeddingService.java

package com.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/**
 * Embedding 服务
 *
 * 职责:把文字转换成向量(数字数组)
 *
 * 类比:翻译官
 * - 输入:人类的语言(文字)
 * - 输出:计算机能理解的语言(向量)
 *
 * 为什么需要 Embedding?
 * - 计算机不能直接理解文字的意思
 * - 但计算机可以计算数字之间的距离
 * - 所以我们把文字变成数字,就能通过计算距离来找相似的内容
 */
@Slf4j
@Service
public class EmbeddingService {

    /**
     * Spring AI 提供的 Embedding 模型
     * 会自动调用 OpenAI 的 Embedding API
     */
    private final EmbeddingModel embeddingModel;

    public EmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    /**
     * 将单个文本转换为向量
     *
     * @param text 要转换的文本
     * @return 向量(float 数组)
     */
    public float[] embed(String text) {
        log.debug("正在生成 Embedding,文本长度: {}", text.length());

        // 调用 Embedding 模型
        float[] vector = embeddingModel.embed(text);

        log.debug("Embedding 生成完成,维度: {}", vector.length);
        return vector;
    }

    /**
     * 批量将文本转换为向量
     *
     * 批量处理比逐个处理更高效(减少 API 调用次数)
     *
     * @param texts 文本列表
     * @return 向量列表
     */
    public List<float[]> embedBatch(List<String> texts) {
        log.info("批量生成 Embedding,数量: {}", texts.size());

        // 构建请求
        EmbeddingRequest request = new EmbeddingRequest(texts, null);

        // 调用 API
        EmbeddingResponse response = embeddingModel.call(request);

        // 提取向量
        List<float[]> vectors = response.getResults().stream()
                .map(result -> {
                    // 将 Double[] 转换为 float[]
                    Double[] doubleArray = result.getOutput();
                    float[] floatArray = new float[doubleArray.length];
                    for (int i = 0; i < doubleArray.length; i++) {
                        floatArray[i] = doubleArray[i].floatValue();
                    }
                    return floatArray;
                })
                .collect(Collectors.toList());

        log.info("批量 Embedding 完成,生成 {} 个向量", vectors.size());
        return vectors;
    }

    /**
     * 计算两个向量的余弦相似度
     *
     * 公式:cos(θ) = (A·B) / (|A| × |B|)
     *
     * 结果范围:-1 到 1
     * - 1: 完全相同
     * - 0: 完全不相关
     * - -1: 完全相反
     *
     * @param a 向量 A
     * @param b 向量 B
     * @return 相似度
     */
    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException("向量维度不匹配");
        }

        double dotProduct = 0;  // 点积
        double normA = 0;       // A 的模
        double normB = 0;       // B 的模

        for (int i = 0; i < a.length; i++) {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }

        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}

向量存储服务:VectorStoreService.java

package com.rag.service;

import com.rag.model.Document;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.sql.ResultSet;
import java.util.*;

/**
 * 向量存储服务
 *
 * 职责:
 * 1. 把文档(内容 + 向量)存入数据库
 * 2. 根据向量相似度检索文档
 *
 * 类比:图书馆管理员
 * - 存储:把书放到书架上(带索引)
 * - 检索:根据你的需求找最相关的书
 */
@Slf4j
@Service
public class VectorStoreService {

    private final JdbcTemplate jdbcTemplate;

    public VectorStoreService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    /**
     * 存储文档(包含向量)
     *
     * @param document 文档对象
     */
    public void store(Document document) {
        String sql = """
                INSERT INTO documents (content, metadata, embedding)
                VALUES (?::jsonb, ?::jsonb, ?::vector)
                """;

        // 将 float[] 转换为 PostgreSQL vector 格式: [0.1,0.2,0.3,...]
        String vectorStr = floatArrayToVector(document.getEmbedding());

        // 将 metadata 转换为 JSON 字符串
        String metadataJson = mapToJson(document.getMetadata());

        jdbcTemplate.update(sql,
                document.getContent(),
                metadataJson,
                vectorStr
        );

        log.debug("文档存储成功,内容长度: {}", document.getContent().length());
    }

    /**
     * 批量存储文档
     *
     * @param documents 文档列表
     */
    public void storeBatch(List<Document> documents) {
        log.info("批量存储文档,数量: {}", documents.size());

        for (Document doc : documents) {
            store(doc);
        }

        log.info("批量存储完成");
    }

    /**
     * 向量相似度检索
     *
     * 使用余弦距离(cosine distance)进行检索
     * pgvector 的 <=> 操作符计算的就是余弦距离
     * 余弦距离 = 1 - 余弦相似度
     *
     * @param queryEmbedding 查询向量
     * @param topK 返回的文档数量
     * @return 相关文档列表
     */
    public List<Document> searchByVector(float[] queryEmbedding, int topK) {
        String sql = """
                SELECT id, content, metadata, embedding <=> ?::vector AS distance
                FROM documents
                ORDER BY distance ASC
                LIMIT ?
                """;

        String vectorStr = floatArrayToVector(queryEmbedding);

        List<Document> results = jdbcTemplate.query(sql, (rs, rowNum) -> mapToDocument(rs), vectorStr, topK);

        log.info("向量检索完成,返回 {} 个文档", results.size());
        return results;
    }

    /**
     * 获取文档总数
     */
    public long count() {
        Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM documents", Long.class);
        return count != null ? count : 0;
    }

    /**
     * 清空所有文档
     */
    public void deleteAll() {
        jdbcTemplate.update("DELETE FROM documents");
        log.info("已清空所有文档");
    }

    /**
     * 将 float[] 转换为 PostgreSQL vector 格式
     *
     * 输入: [0.1f, 0.2f, 0.3f]
     * 输出: "[0.1,0.2,0.3]"
     */
    private String floatArrayToVector(float[] array) {
        StringBuilder sb = new StringBuilder("[");
        for (int i = 0; i < array.length; i++) {
            if (i > 0) {
                sb.append(",");
            }
            sb.append(array[i]);
        }
        sb.append("]");
        return sb.toString();
    }

    /**
     * 将 Map 转换为 JSON 字符串
     */
    private String mapToJson(Map<String, Object> map) {
        try {
            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(map);
        } catch (Exception e) {
            return "{}";
        }
    }

    /**
     * 将 ResultSet 映射为 Document 对象
     */
    private Document mapToDocument(ResultSet rs) throws java.sql.SQLException {
        return Document.builder()
                .id(rs.getLong("id"))
                .content(rs.getString("content"))
                .metadata(parseMetadata(rs.getString("metadata")))
                .build();
    }

    /**
     * 解析 metadata JSON
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> parseMetadata(String json) {
        try {
            if (json == null || json.isEmpty()) {
                return new HashMap<>();
            }
            return new com.fasterxml.jackson.databind.ObjectMapper()
                    .readValue(json, HashMap.class);
        } catch (Exception e) {
            return new HashMap<>();
        }
    }
}

注意这个类用的是 JdbcTemplate 直接写 SQL,而不是 Spring AI 提供的 VectorStore 抽象。原因很实际:直接写 SQL 能看到每一步到底发生了什么,而 VectorStore 把查询、过滤、相似度计算都封装起来了,出问题时你只能靠猜。Demo 阶段先手写,理解透了再换抽象。

检索那条 SQL 值得盯一眼:

SELECT id, content, metadata, embedding <=> ?::vector AS distance
FROM documents
ORDER BY distance ASC
LIMIT ?

<=> 是余弦距离算子,ORDER BY distance ASC 表示取距离最小的——也就是最相似的。排序方向写反不会报错,这是最危险的那种 bug。

RAG 主服务:RagService.java

package com.rag.service;

import com.rag.model.Document;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/**
 * RAG 主服务
 *
 * 职责:串联整个 RAG 流程
 *
 * 类比:项目经理
 * - 协调各个部门(切分、Embedding、检索、生成)
 * - 确保流程顺利执行
 *
 * 流程:
 * 1. 接收用户问题
 * 2. 把问题转换为向量(Embedding)
 * 3. 用向量去检索相关文档
 * 4. 把文档和问题组装成 Prompt
 * 5. 调用大模型生成回答
 */
@Slf4j
@Service
public class RagService {

    private final EmbeddingService embeddingService;
    private final VectorStoreService vectorStoreService;
    private final ChatModel chatModel;

    @Value("${rag.top-k:5}")
    private int topK;

    public RagService(EmbeddingService embeddingService,
                      VectorStoreService vectorStoreService,
                      ChatModel chatModel) {
        this.embeddingService = embeddingService;
        this.vectorStoreService = vectorStoreService;
        this.chatModel = chatModel;
    }

    /**
     * RAG 问答主流程
     *
     * @param question 用户问题
     * @return 回答
     */
    public String ask(String question) {
        log.info("收到用户问题: {}", question);

        // ========== 第一步:把问题转换为向量 ==========
        log.info("步骤 1: 生成问题的 Embedding...");
        float[] questionEmbedding = embeddingService.embed(question);

        // ========== 第二步:用向量检索相关文档 ==========
        log.info("步骤 2: 检索相关文档,Top-K: {}", topK);
        List<Document> relatedDocs = vectorStoreService.searchByVector(questionEmbedding, topK);

        if (relatedDocs.isEmpty()) {
            log.warn("未找到相关文档");
            return "抱歉,知识库中没有找到相关信息。";
        }

        log.info("找到 {} 个相关文档", relatedDocs.size());

        // ========== 第三步:组装 Prompt ==========
        log.info("步骤 3: 组装 Prompt...");
        String prompt = buildPrompt(question, relatedDocs);

        // ========== 第四步:调用大模型生成回答 ==========
        log.info("步骤 4: 调用大模型生成回答...");
        String answer = callLlm(prompt);

        log.info("RAG 流程完成");
        return answer;
    }

    /**
     * 组装 Prompt
     *
     * 把检索到的文档和用户问题组装成一个完整的 Prompt
     *
     * @param question    用户问题
     * @param documents   检索到的文档
     * @return 完整的 Prompt
     */
    private String buildPrompt(String question, List<Document> documents) {
        // 构建参考资料部分
        StringBuilder contextBuilder = new StringBuilder();
        for (int i = 0; i < documents.size(); i++) {
            Document doc = documents.get(i);
            contextBuilder.append(String.format("[%d] %s\n", i + 1, doc.getContent()));
        }

        // 组装完整的 Prompt
        String systemPrompt = """
                你是一个企业知识库问答助手。你的任务是基于提供的参考资料回答用户问题。

                回答要求:
                1. 只基于参考资料回答,不要编造信息
                2. 如果参考资料中没有相关信息,明确告知用户
                3. 引用来源,使用 [1]、[2] 这样的格式
                4. 回答要简洁明了,直接回答问题
                """;

        String userPrompt = String.format("""
                ## 参考资料:
                %s

                ## 用户问题:
                %s

                ## 请回答:
                """, contextBuilder, question);

        // 调用大模型
        return userPrompt;
    }

    /**
     * 调用大模型
     *
     * @param prompt 完整的 Prompt
     * @return 模型回答
     */
    private String callLlm(String prompt) {
        try {
            SystemMessage systemMessage = new SystemMessage("""
                    你是一个企业知识库问答助手。请基于参考资料回答用户问题。
                    如果资料中没有相关信息,请明确告知。
                    引用来源时使用 [1]、[2] 这样的格式。
                    """);

            UserMessage userMessage = new UserMessage(prompt);

            Prompt chatPrompt = new Prompt(List.of(systemMessage, userMessage));
            org.springframework.ai.chat.model.ChatResponse response = chatModel.call(chatPrompt);

            return response.getResult().getOutput().getContent();
        } catch (Exception e) {
            log.error("调用大模型失败", e);
            return "抱歉,生成回答时出现错误:" + e.getMessage();
        }
    }
}

ask() 方法是整条链路的主干,四步对应 2.1 到 2.4 讲的四个环节:问题向量化 → 向量检索 → 组装 Prompt → 调用模型。

这里有一个可以改进的小地方:buildPrompt 里拼了一个 systemPrompt 字符串,但方法返回的是 userPrompt,那个变量根本没被使用;真正生效的 system 消息是在 callLlm 里重新构造的。功能上没问题,但同一份指令写了两遍、还用错了地方,改 Prompt 时很容易改错一处。

控制器:RagController.java

package com.rag.controller;

import com.rag.model.Document;
import com.rag.service.DocumentService;
import com.rag.service.EmbeddingService;
import com.rag.service.RagService;
import com.rag.service.VectorStoreService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * RAG API 控制器
 *
 * 提供 REST API 接口:
 * - POST /api/rag/upload: 上传文档
 * - POST /api/rag/ask: 提问
 * - GET /api/rag/stats: 查看统计
 */
@Slf4j
@RestController
@RequestMapping("/api/rag")
public class RagController {

    private final DocumentService documentService;
    private final EmbeddingService embeddingService;
    private final VectorStoreService vectorStoreService;
    private final RagService ragService;

    public RagController(DocumentService documentService,
                         EmbeddingService embeddingService,
                         VectorStoreService vectorStoreService,
                         RagService ragService) {
        this.documentService = documentService;
        this.embeddingService = embeddingService;
        this.vectorStoreService = vectorStoreService;
        this.ragService = ragService;
    }

    /**
     * 上传文档接口
     *
     * 流程:
     * 1. 接收文档内容
     * 2. 切分成小块
     * 3. 生成 Embedding
     * 4. 存入数据库
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadDocument(@RequestBody UploadRequest request) {
        log.info("收到文档上传请求,来源: {}", request.getSource());

        try {
            // 第一步:切分文档
            List<Document> chunks = documentService.splitDocument(request.getContent(), request.getSource());

            // 第二步:为每个 Chunk 生成 Embedding
            log.info("正在生成 Embedding...");
            List<String> texts = chunks.stream()
                    .map(Document::getContent)
                    .collect(java.util.stream.Collectors.toList());

            List<float[]> embeddings = embeddingService.embedBatch(texts);

            // 第三步:将 Embedding 设置到文档中
            for (int i = 0; i < chunks.size(); i++) {
                chunks.get(i).setEmbedding(embeddings.get(i));
            }

            // 第四步:存入数据库
            vectorStoreService.storeBatch(chunks);

            return ResponseEntity.ok(Map.of(
                    "success", true,
                    "message", "文档上传成功",
                    "chunks", chunks.size()
            ));
        } catch (Exception e) {
            log.error("文档上传失败", e);
            return ResponseEntity.badRequest().body(Map.of(
                    "success", false,
                    "message", "上传失败: " + e.getMessage()
            ));
        }
    }

    /**
     * 提问接口
     */
    @PostMapping("/ask")
    public ResponseEntity<Map<String, Object>> ask(@RequestBody AskRequest request) {
        log.info("收到提问: {}", request.getQuestion());

        try {
            String answer = ragService.ask(request.getQuestion());
            return ResponseEntity.ok(Map.of(
                    "success", true,
                    "answer", answer
            ));
        } catch (Exception e) {
            log.error("问答失败", e);
            return ResponseEntity.badRequest().body(Map.of(
                    "success", false,
                    "message", "问答失败: " + e.getMessage()
            ));
        }
    }

    /**
     * 统计接口
     */
    @GetMapping("/stats")
    public ResponseEntity<Map<String, Object>> getStats() {
        long count = vectorStoreService.count();
        return ResponseEntity.ok(Map.of(
                "totalDocuments", count
        ));
    }

    /**
     * 清空知识库
     */
    @DeleteMapping("/clear")
    public ResponseEntity<Map<String, Object>> clearAll() {
        vectorStoreService.deleteAll();
        return ResponseEntity.ok(Map.of(
                "success", true,
                "message", "知识库已清空"
        ));
    }

    // ========== 请求体类 ==========

    @Data
    public static class UploadRequest {
        private String content;  // 文档内容
        private String source;   // 文档来源(文件名)
    }

    @Data
    public static class AskRequest {
        private String question; // 用户问题
    }
}

四、跑起来验证

4.1 启动应用

# 1. 确保 PostgreSQL 和 pgvector 已启动
# 2. 创建数据库和表
psql -U postgres -d rag_db -f init.sql

# 3. 设置环境变量
export OPENAI_API_KEY=sk-xxx

# 4. 启动应用
cd demo1-basic-rag
mvn spring-boot:run

4.2 上传文档

# 上传一份测试文档
curl -X POST http://localhost:8080/api/rag/upload \
  -H "Content-Type: application/json" \
  -d '{
    "content": "公司年假政策:员工入职满一年后,可享受带薪年假10天。入职满五年后,年假增至15天。入职满十年后,年假增至20天。年假需提前3天申请,经直属领导批准后方可休假。年假不可跨年累积,未使用的年假将在年底清零。\n\n公司病假政策:员工因病需要休息的,可申请病假。病假期间工资照发。病假需提供医院证明,3天以内由直属领导批准,3天以上需部门经理批准。",
    "source": "员工手册.pdf"
  }'

4.3 提问

# 提问
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{
    "question": "入职3年的员工年假有多少天?"
  }'

# 预期回答:
# 根据[1]员工手册,入职满一年后可享受带薪年假10天。因此,入职3年的员工年假为10天。

预期能看到类似这样的回答:

根据[1]员工手册,入职满一年后可享受带薪年假10天。因此,入职3年的员工年假为10天。

判断这个回答好不好,看两点:引用标注对不对得上([1] 是否真是讲年假那段),以及有没有超出资料范围。前者属于检索问题,后者属于生成问题——第五篇会分别量化这两个维度。

4.4 再问几个

# 问题2
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{
    "question": "病假需要什么手续?"
  }'

# 问题3
curl -X POST http://localhost:8080/api/rag/ask \
  -H "Content-Type: application/json" \
  -d '{
    "question": "年假可以累积到明年吗?"
  }'

这一步别跳过。只问一个「年假」就以为跑通了,是最常见的自欺欺人。 至少换三种问法:换个说法但意思相同的(测语义召回)、换成一个文档里的生僻专有名词(测关键词召回)、问一个文档里根本没有的问题(测它会不会编)。第三种最能暴露问题,而且第一版大概率答不好。


五、最简版本哪里不够

跑通之后,你手上这套东西的短板会立刻显形。把它们列清楚,后面四篇才有靶子:

症状根因对应优化
换个说法就搜不到,或者专有名词搜不准单一向量检索的固有短板混合检索(第三篇)
Top-5 里塞了一堆「像但不相关」的粗排只保证召回,不保证排序质量Rerank 精排(第四篇)
回答被切断的句子误导定长切分破坏语义边界结构感知切分
改来改去不知道有没有变好全靠手感,没有量化评估体系(第五篇)

这四条里,最后一条最要命。没有评估,前面三条优化你都无法判断是真有效还是自我感觉良好。


六、常见误区

❌「链路上任何一环出错,最终答案都会看起来『差不多能看』」 这是 RAG 最难调试的地方。检索没召回、排序排反了、Prompt 指令没生效,模型都倾向于给你一段通顺的话。它不报错,它糊弄你。 所以必须逐段验证:先把检索结果打出来看,确认召回了;再单独看拼好的 Prompt,确认组装对了;最后才看模型输出。跳过中间步骤直接调 Prompt,是在瞎猜。

❌「建了向量索引就一定比不建快」 小数据量下 PostgreSQL 很可能压根不用那个索引(顺序扫描成本更低),这是执行计划器的正确选择,不是 bug。而且 IVFFlat 是近似索引,本身会牺牲一部分召回率换速度。索引不是免费的,更不是越早建越好——数据灌完、量级上来了再建。

❌「chunk size 调到最优解就行了」 500 只是个起点,不是答案。这个值取决于你的文档长什么样(技术文档的段落密度和 FAQ 完全不同)以及你打算问什么类型的问题(要精确事实还是概要理解)。唯一靠谱的调法是拿评估集跑对比——也就是第五篇要建的东西。在此之前,任何「最佳实践值」都是别人场景里的经验。

❌「中文按字符切分是等价的」 不是。substring 按 UTF-16 码元切,正好切开一个代理对(比如 emoji)时,会得到半个字符——Java 的 String 能安然存下这个残片,不抛异常,但它在 Embedding 时会变成乱码或直接被丢弃。用 DocumentService 里这套定长切分时,这一点要心里有数。

❌「检索不到就调大 Top-K」 把 K 从 5 调到 20,无效片段也一起进来了,模型被噪声误导的概率同步上升,而且 Prompt 变长、成本变高。K 不是越大越好,它和 Rerank 的配合才是正解——粗排多召回一点,精排再筛掉,这是第四篇的主题。


七、自检清单

  • 能从零说出 ask() 的四步,以及每步对应哪个类
  • 说得清 chunk-overlap 解决什么问题,以及它解决不了什么
  • 知道 <=> 返回的是距离不是相似度,SQL 排序要用 ASC
  • 知道 vector(1536) 里的维度为什么是这个数,以及换模型时它意味着什么
  • 说得清 IVFFlat 索引为什么不该在空表上建
  • 能用 splitDocument 和 splitDocumentByParagraph 说出两种切分策略的差别
  • 问过至少一个「文档里根本没有」的问题,并观察到模型的表现
  • 知道 buildPrompt 里那个没用上的 systemPrompt 是怎么回事

附:延伸阅读

相关资料

资源说明
pgvector: IndexingIVFFlat 与 HNSW 的适用场景、lists 与 probes 的调法
Spring AI: ChatClient & ChatModelSystemMessage / UserMessage 组装的实际规则
OpenAI: Embeddingstext-embedding-3-small 的维度、定价与选型建议
Dense Passage Retrieval (Karpukhin et al., 2020)双塔向量检索的经典实现,理解 Embedding 检索的来路
Lost in the Middle为什么「多塞几段参考资料」不能解决问题

关联笔记


最后更新:2026-09-25