目录
一、这个 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: Indexing | IVFFlat 与 HNSW 的适用场景、lists 与 probes 的调法 |
| Spring AI: ChatClient & ChatModel | SystemMessage / UserMessage 组装的实际规则 |
| OpenAI: Embeddings | text-embedding-3-small 的维度、定价与选型建议 |
| Dense Passage Retrieval (Karpukhin et al., 2020) | 双塔向量检索的经典实现,理解 Embedding 检索的来路 |
| Lost in the Middle | 为什么「多塞几段参考资料」不能解决问题 |
关联笔记
- RAG 实战(一):RAG 是什么,以及环境怎么搭 —— pgvector 安装与 Embedding 模型选型
- RAG 实战(三):混合检索与 RRF 融合 —— 解决这一篇暴露出的「搜不准」
最后更新:2026-09-25