Spring Boot集成PgVector实现RAG向量检索实战
1. 项目概述:为什么用 PostgreSQL 做向量检索,而不是换数据库?
你有没有试过在 Spring Boot 项目里接入大模型做问答,结果一问“我们上季度华东区销售额最高的三个客户是谁”,模型张口就胡编?不是它不想答对,是它根本没见过你 ERP 里的客户主数据、销售订单明细和财务凭证——这些结构化业务数据,压根没进它的训练语料库。这时候光靠 prompt 工程调教,就像用胶带缠住漏水的水管:临时能堵一阵,但压力一大,水照样喷。
RAG(Retrieval Augmented Generation)就是那个更可靠的修理工。它不指望大模型凭空编造,而是先从你自己的知识库(比如产品手册、客服工单、内部 Wiki)里,精准捞出和问题最相关的几段原文,再把这几段“喂”给大模型,让它基于真实上下文生成答案。这招一出,幻觉率断崖式下降,答案可信度肉眼可见地稳。
但 RAG 的命门不在大模型,而在“捞原文”这一步——也就是向量检索。你得把文档切块、转成向量、存起来、再用用户问题的向量去海里捞针。很多人第一反应是上专用向量数据库:Pinecone、Weaviate、Qdrant。听起来很酷,可真落地到企业级 Java 项目里,你会发现三座大山:运维成本高(要额外起服务、配集群、搞监控)、数据孤岛严重(业务数据在 PostgreSQL,向量又存另一套库,双写一致性怎么保?)、权限体系割裂(DBA 管 PG,AI 工程师管向量库,出了问题谁背锅?)。
PgVector 就是来拆这三座山的。它不是一个新数据库,而是一个 PostgreSQL 的开源扩展(extension),装上就能用。你原来那套 PostgreSQL 实例,今天还在跑订单查询、库存盘点、报表导出,明天加一行 CREATE EXTENSION vector; ,它立刻变身向量数据库。文档 chunk 和它的向量,直接存在你已有的 document_chunks 表里,和 customer_id 、 created_at 字段肩并肩;ACL 权限、备份策略、连接池配置,全复用现有 DBA 的那一套。Spring Boot 里连一个数据源,写 SQL 或 JPA 查询,和查普通字段毫无区别——连 Hibernate 都不用改配置。
我去年在一家做工业设备 SaaS 的公司落地这个方案时,技术负责人第一句就问:“你们确定不用单独部署向量库?” 我给他看了三张图:一张是传统架构里,PG + Pinecone + Spring Boot 之间画着密密麻麻的同步箭头和告警图标;一张是 PgVector 架构,所有线条都收束到同一个 PostgreSQL 圆圈里;第三张是上线后三个月的运维看板——向量检索相关告警归零。他当场拍板:“就用 PgVector,省下的两台云服务器预算,够买十箱咖啡了。”
关键词“SpringAI”、“RAG”、“PgVector”在这里不是堆砌术语,而是锚定了一个非常具体的工程现实:用 Spring 生态最顺手的方式(Spring Boot + Spring Data JPA),把大模型能力无缝缝进你已有的、正在跑生产流量的关系型数据库里。它解决的不是“能不能做 RAG”的学术问题,而是“怎么让 RAG 在周一早上九点准时上线,且 DBA 不用加班”的落地问题。适合谁?不是刚学完 LangChain 教程的在校生,而是手上有 200 张表、3 个微服务、每天被业务方催着上线新功能的 Java 后端工程师——你不需要成为向量数据库专家,只要会写 SQL 和 JPA,就能把 RAG 跑起来。
2. 核心设计思路:为什么选 Spring AI 而不是自己封装 OpenAI SDK?
很多 Java 工程师看到 RAG,第一反应是翻出 OpenAI 官方 Java SDK,手动拼接 HTTP 请求:先调 /embeddings 接口把问题转成向量,再拼 SQL 查 PgVector,最后调 /chat/completions 把检索结果塞进 prompt。逻辑没错,但真写下去,你会掉进五个坑里:
第一坑是 协议细节黑洞 。OpenAI 的 /embeddings 接口要求 input 字段必须是字符串或字符串数组,但如果你的 chunk 是富文本(含 HTML 标签、Markdown 表格),直接传进去,embedding 模型会把 <table> 当成普通字符学习,向量质量打折;而 /chat/completions 的 messages 数组里, role 只能是 system / user / assistant ,少一个冒号、多一个空格,HTTP 返回 400。这些细节官方文档藏在犄角旮旯,等你线上报错才去查,至少浪费半天。
第二坑是 重试与熔断裸奔 。大模型 API 不是内网服务,网络抖动、限流、超时是家常便饭。你自己写的 HTTP 调用,如果没加指数退避重试、没设 circuit breaker,一次 ConnectionTimeoutException 就能让整个问答接口雪崩。Spring Cloud CircuitBreaker 或 Resilience4j 虽然能补,但那是额外一层抽象,配置分散,日志难追踪。
第三坑是 提示词模板散落各处 。RAG 的 prompt 不是固定字符串,它要动态注入检索到的 context、当前用户角色、业务规则(比如“只回答 2023 年之后的数据”)。你可能在 Controller 里拼一段,在 Service 里拼一段,甚至在 DTO 里硬编码。等产品经理说“把‘根据文档’改成‘依据最新版操作指南’”,你得 grep 全项目找三遍。
第四坑是 可观测性缺失 。你想知道“为什么这个回答错了”?得手动记下 request id,去查 OpenAI 日志、查 PG 查询日志、查应用日志,再人工对时间戳。而真正的生产环境,你需要的是:一次请求里,自动记录 embedding 耗时、向量检索耗时、LLM 调用耗时、最终 prompt 内容、返回 token 数——这些指标要能直接打到 Prometheus,报警阈值能按接口维度配。
第五坑是 模型切换成本高 。今天用 OpenAI,明天要切到本地部署的 Llama3-70B,后天老板说采购千问 Qwen2,你得重写所有 HTTP 调用层,改依赖、改 URL、改参数映射、改响应解析逻辑。
Spring AI 就是为填这五个坑而生的。它不是另一个大模型 SDK,而是一套 面向 AI 应用的 Spring 原生抽象层 。核心就两个接口: EmbeddingClient 负责向量化, ChatClient 负责生成,背后实现可以是 OpenAI、Azure OpenAI、Ollama、HuggingFace、甚至你自研的模型服务。你代码里只依赖这两个接口,具体用哪家,全由 application.yml 里的 spring.ai.* 配置决定。
比如 embedding 这一步,Spring AI 封装了所有脏活:
- 自动处理输入预处理:对长文本分块(chunking),过滤 HTML 标签,截断超长内容(避免
text too long错误); - 内置重试策略:默认 3 次指数退避,失败时抛出统一
AiException; - 提供
EmbeddingOptions让你精细控制:user字段传用户 ID 用于审计,encodingFormat指定 base64 或 float 数组; - 所有调用自动埋点:
spring.ai.embedding.client.requests.total、spring.ai.embedding.client.request.duration直接上报 Micrometer。
ChatClient 更彻底。你不再拼字符串,而是用 SystemPromptTemplate 和 UserPromptTemplate 对象管理提示词。一个典型的 RAG prompt 模板长这样:
@Bean
public PromptTemplate ragPromptTemplate() {
return new PromptTemplate(
"""
你是一个严谨的技术支持助手,只根据以下【参考文档】回答问题。
如果【参考文档】中没有明确信息,必须回答“未找到相关信息”,禁止猜测。
【参考文档】:
{context}
【用户问题】:
{question}
"""
);
}
{context} 和 {question} 是占位符,Spring AI 在执行时自动注入—— context 是你从 PgVector 查出的 chunk 列表, question 是用户原始提问。模板本身是 Spring Bean,可以 @Value 注入配置项,可以 AOP 增强,可以单元测试 mock。切模型?改一行配置: spring.ai.openai.chat.options.model=Qwen2-7B-Instruct ,其他代码零修改。
这不是“为了用框架而用框架”。当你在凌晨两点排查一个线上问题,发现答案错误是因为 embedding 模型把“API”和“ApI”当成了不同词(大小写敏感),而 Spring AI 的 EmbeddingOptions 里有一行 normalize: true 就能解决时,你会明白:所谓生产力工具,就是把那些本该由框架兜底的、枯燥的、易错的细节,真的兜住了。
3. 核心细节解析:PgVector 扩展安装与向量表设计实战
PgVector 不是下载一个 jar 包扔进 lib 目录那么简单。它需要在 PostgreSQL 数据库实例层面加载 C 语言编译的原生扩展,这意味着你的部署方式直接决定了后续能否顺利启用。我见过太多团队卡在这一步,不是因为技术难度高,而是因为没理清“谁负责装、在哪装、怎么验证”。
3.1 三种部署场景下的安装路径
场景一:本地开发(Docker Desktop / Colima) 这是最可控的环境。别用 postgres:latest 镜像,它默认不带 PgVector。必须用官方维护的带扩展镜像:
docker run -d \
--name my-pg \
-e POSTGRES_PASSWORD=devpass \
-p 5432:5432 \
-v $(pwd)/pgdata:/var/lib/postgresql/data \
ankane/pgvector:pg15 # 注意版本匹配!PG 15 用 pg15 镜像
启动后,进入容器执行:
docker exec -it my-pg psql -U postgres -c "CREATE EXTENSION IF NOT EXISTS vector;"
验证是否成功:
docker exec -it my-pg psql -U postgres -c "SELECT * FROM pg_extension WHERE extname = 'vector';"
-- 如果返回一行记录,extversion 字段显示版本号(如 0.7.4),说明安装成功
场景二:云数据库(AWS RDS / Azure Database for PostgreSQL) 这里最容易踩坑。RDS 的 PostgreSQL 引擎默认禁用所有扩展,包括 vector 。你不能像本地一样直接 CREATE EXTENSION 。必须走 AWS 控制台或 CLI:
- 进入 RDS 控制台 → 选择你的 DB 实例 → “配置”页 → “数据库特性”部分 → 找到
vector→ 勾选 → 保存并重启实例(注意:重启会导致分钟级不可用)。 - Azure 同理,在“服务器参数”里搜索
shared_preload_libraries,确保值包含vector,然后在数据库里执行CREATE EXTENSION vector;。
提示:云厂商的扩展白名单是硬限制。如果你用的是阿里云 PolarDB 或腾讯云 TDSQL,它们目前(截至 2024 年中)尚未将 PgVector 加入官方支持列表。此时要么联系厂商提需求,要么降级方案:用
pg_trgm(PostgreSQL 内置的三元语法索引)做轻量级语义检索,精度低但 100% 兼容。
场景三:Kubernetes 生产集群(StatefulSet + PVC) 这是最复杂的场景,也是企业级部署的标配。关键点在于: 扩展必须在数据库初始化阶段就加载,不能等 Pod 启动后再手动执行 。你需要定制一个初始化脚本,在 PostgreSQL 启动前完成扩展创建。
典型流程:
- 编写
init.sql:-- /docker-entrypoint-initdb.d/init.sql CREATE DATABASE rag_app; \c rag_app CREATE EXTENSION IF NOT EXISTS vector; - 构建自定义镜像(Dockerfile):
FROM postgres:15 COPY init.sql /docker-entrypoint-initdb.d/ # 不需要 RUN apt-get install,ankane/pgvector 镜像已编译好 - StatefulSet 的 volumeMounts 挂载 PVC 到
/var/lib/postgresql/data,确保数据持久化。
验证命令必须嵌入健康检查(liveness probe):
livenessProbe:
exec:
command:
- sh
- -c
- psql -U $POSTGRES_USER -d $POSTGRES_DB -t -c "SELECT 1 FROM pg_extension WHERE extname='vector';" | grep -q "1"
initialDelaySeconds: 30
periodSeconds: 10
如果 probe 失败,K8s 会自动重启 Pod,避免“扩展没装好,应用连上去就报错”的静默故障。
3.2 向量表设计:为什么用 vector(1536) 而不是 vector(768) ?
表结构看着简单,但每个字段类型和约束都藏着经验:
CREATE TABLE document_chunk (
id BIGSERIAL PRIMARY KEY,
document_id VARCHAR(64) NOT NULL, -- 关联原始文档(如 manual_v2.pdf)
chunk_index INTEGER NOT NULL, -- 在原文中的顺序(第1块、第2块...)
content TEXT NOT NULL, -- 原始文本内容(清洗后,无 HTML)
embedding VECTOR(1536) NOT NULL, -- 向量,维度必须匹配 embedding 模型
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB -- 业务元数据:source_url, author, tags...
);
-- 创建向量索引(最关键!)
CREATE INDEX ON document_chunk
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
重点解释三个参数:
VECTOR(1536) 的维度 :这必须和你选用的 embedding 模型输出维度严格一致。OpenAI 的 text-embedding-3-small 输出 1536 维, text-embedding-3-large 是 3072 维, text-embedding-ada-002 是 1536 维。千万别想当然填 768 (那是 BERT 类模型的常见维度)。填错的后果是:插入数据时报 ERROR: column "embedding" is of type vector but expression is of type vector(768) ,或者查询时距离计算完全失真。Spring AI 的 OpenAiEmbeddingOptions 里有个 dimensions 参数,必须和表定义一致。
ivfflat 索引类型 :PgVector 支持两种索引: ivfflat (倒排文件扁平)和 hnsw (分层导航小世界)。 hnsw 精度更高、查询更快,但内存占用大,且 只在 PgVector 0.5.0+ 版本支持 。如果你用的是 RDS 上较老的 PgVector(如 0.4.x), hnsw 会报错 ERROR: unrecognized index method "hnsw" 。 ivfflat 是兼容性之选,它把向量空间划分成 lists 个簇(cluster),查询时先定位最近的几个簇,再在簇内暴力计算。 lists = 100 是经验值: lists 值约等于 sqrt(总向量数) 。比如你预计存 100 万 chunk, sqrt(1000000)=1000 ,就设 lists=1000 。设太小(如 10),召回率暴跌;设太大(如 10000),索引体积爆炸,写入变慢。
vector_cosine_ops 操作符类 :这决定了相似度怎么算。 cosine (余弦相似度)是最常用的选择,值域 [-1,1],越接近 1 越相似。还有 l2 (欧氏距离)、 inner_product (内积)。别乱选! cosine 和 inner_product 在向量已归一化(norm=1)时数学等价,但 OpenAI 的 embedding 默认未归一化,必须用 cosine 。用错操作符, ORDER BY embedding <=> ? 的排序结果完全不可信。
注意:
ivfflat索引需要定期VACUUM和ANALYZE。我们在线上加了定时任务,每天凌晨 2 点执行:VACUUM ANALYZE document_chunk; -- 并重建索引(对大表,用 CONCURRENTLY 避免锁表) DROP INDEX CONCURRENTLY IF EXISTS idx_document_chunk_embedding; CREATE INDEX CONCURRENTLY idx_document_chunk_embedding ON document_chunk USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
3.3 Spring Data JPA 映射:如何让 JPA 理解 vector 类型?
JPA 默认不认识 vector 这个 PostgreSQL 特有类型,直接映射会报 org.hibernate.MappingException: No Dialect mapping for JDBC type: 2000 。解决方案是注册自定义方言(Dialect)和类型转换器(AttributeConverter)。
第一步,扩展 PostgreSQL 方言:
public class CustomPostgreSqlDialect extends PostgreSQLDialect {
public CustomPostgreSqlDialect() {
super();
// 告诉 Hibernate:JDBC type 2000(vector)对应 Java 的 float[] 数组
this.registerColumnType(Types.OTHER, "vector($l)");
}
}
第二步,写类型转换器:
@Converter(autoApply = true)
public class VectorConverter implements AttributeConverter<float[], String> {
@Override
public String convertToDatabaseColumn(float[] attribute) {
if (attribute == null) return null;
// 转成 PostgreSQL vector 字面量格式:'[1.23,4.56,7.89]'
return "[" + Arrays.stream(attribute)
.mapToObj(String::valueOf)
.collect(Collectors.joining(",")) + "]";
}
@Override
public float[] convertToEntityAttribute(String dbData) {
if (dbData == null || !dbData.startsWith("[")) return null;
// 解析 '[1.23,4.56]' -> float[]{1.23f, 4.56f}
String content = dbData.substring(1, dbData.length() - 1);
return Arrays.stream(content.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.mapToDouble(Double::parseDouble)
.mapToFloat((double v) -> (float) v)
.toArray();
}
}
第三步,在 application.yml 中指定方言:
spring:
jpa:
database-platform: com.example.CustomPostgreSqlDialect
实体类就这么写:
@Entity
@Table(name = "document_chunk")
public class DocumentChunk {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String documentId;
private Integer chunkIndex;
private String content;
@Convert(converter = VectorConverter.class)
private float[] embedding; // 直接用 float[],JPA 自动转换
private LocalDateTime createdAt;
@Convert(converter = JsonBinaryConverter.class) // 自定义 JSONB 转换器
private Map<String, Object> metadata;
}
实测下来,这套映射稳定支撑日均 50 万次向量查询。唯一要注意的是: VectorConverter 的 convertToDatabaseColumn 方法里,字符串拼接必须严格匹配 PostgreSQL 的 vector 格式(方括号、逗号分隔、无空格),否则插入会报 ERROR: malformed vector literal 。
4. 实操过程:从零构建 Spring Boot RAG 服务(含完整代码)
现在把所有零件组装起来。我们用一个极简但真实的场景:为公司内部的《Spring Boot 最佳实践》PDF 文档构建问答机器人。目标:用户问“如何配置多数据源?”,返回 PDF 中对应章节的原文片段,并生成总结。
4.1 项目初始化与依赖
用 start.spring.io 生成基础项目,勾选:
- Spring Web
- Spring Data JPA
- PostgreSQL Driver
- Lombok(简化 POJO)
然后手动添加 Spring AI 和 PgVector 依赖(注意版本对齐):
<!-- pom.xml -->
<properties>
<spring-ai.version>0.8.1</spring-ai.version>
<spring-boot.version>3.2.5</spring-boot.version>
</properties>
<dependencies>
<!-- Spring AI 核心 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring AI 对 PostgreSQL 向量检索的支持 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- JPA 与 PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
关键点: spring-ai-pgvector-store-spring-boot-starter 这个 starter 会自动配置 PgVectorStore Bean,它封装了所有向量 CRUD 操作,你不用手写 INSERT INTO ... VALUES (...) 。
4.2 配置文件:让 Spring AI 连上你的 PG 和 OpenAI
application.yml 是整个链路的中枢:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/rag_demo
username: postgres
password: devpass
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate # 生产环境务必改为 none!
show-sql: false
properties:
hibernate:
format_sql: true
# Spring AI 配置
ai:
openai:
api-key: ${OPENAI_API_KEY} # 强烈建议用环境变量!
chat:
options:
model: gpt-4o-mini # 成本低、速度快,RAG 场景足够
embedding:
options:
model: text-embedding-3-small # 1536维,与表定义匹配
dimensions: 1536
# PgVector Store 配置
pgvector:
store:
table-name: document_chunk # 必须和你建的表名一致
embedding-column: embedding # 向量列名
document-column: content # 文本内容列名
metadata-column: metadata # JSONB 元数据列名
id-column: id # 主键列名
similarity-threshold: 0.2 # 相似度阈值,0.0~1.0,值越大越严格
提示:
similarity-threshold: 0.2是经验值。余弦相似度 0.2 意味着向量夹角约 78 度,已经比较宽松。如果召回结果太多噪音,可提高到 0.4~0.5;如果召回太少(经常返回空),可降到 0.1。这个值要结合你的 embedding 模型和业务场景调优,没有银弹。
4.3 文档加载与向量化:PDF 解析的避坑指南
RAG 的效果,七分靠数据,三分靠模型。PDF 解析是第一道关卡。别用 pdfbox 或 itext 手动解析——它们对扫描版 PDF、复杂表格、公式支持极差。我们用 unstructured-io 的 Java SDK(通过 REST API 调用),它背后是多模型融合的解析引擎。
步骤:
- 下载
unstructured服务(Docker):docker run -d -p 8000:8000 --rm -v $(pwd)/data:/data unstructured-io/unstructured-api:latest - 编写解析服务:
@Service public class PdfParserService { private final RestTemplate restTemplate = new RestTemplate(); public List<Document> parsePdf(String pdfPath) throws IOException { // 读取 PDF 文件为字节数组 byte[] pdfBytes = Files.readAllBytes(Paths.get(pdfPath)); // 构建 multipart 请求 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); body.add("files", new ByteArrayResource(pdfBytes) { @Override public String getFilename() { return "best-practice.pdf"; } }); body.add("strategy", "hi_res"); // 高精度解析策略 body.add("include_page_breaks", "false"); HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers); // 调用 unstructured API ResponseEntity<String> response = restTemplate.exchange( "http://localhost:8000/general/v0/general", HttpMethod.POST, requestEntity, String.class ); // 解析 JSON 响应,提取 text 字段 JsonNode rootNode = new ObjectMapper().readTree(response.getBody()); List<Document> documents = new ArrayList<>(); for (JsonNode element : rootNode) { String text = element.path("text").asText(); if (text != null && text.trim().length() > 50) { // 过滤短文本和页眉页脚 documents.add(new Document(text, Map.of("source", pdfPath))); } } return documents; } } - 分块(Chunking)策略:不要用固定长度切分(如每 500 字符一刀)。Spring AI 内置
TokenTextSplitter,按 token 数切分,更符合 LLM 处理习惯:
为什么是 300?因为 GPT-4o-mini 的上下文窗口是 128K token,但你要留出空间给 system prompt、user question、生成答案。假设 prompt 占 200 token,问题占 100 token,答案预留 500 token,那么留给 context 的空间约 127K token。一次检索返回 top-k=3 个 chunk,每个 300 token,总共 900 token,绰绰有余。@Bean public TextSplitter textSplitter() { return new TokenTextSplitter.Builder() .encodingName("cl100k_base") // OpenAI 模型的 tokenizer .maxTokensPerChunk(300) // 每块最多 300 token .build(); }
4.4 RAG 服务核心逻辑:三步串联
Controller 层极简:
@RestController
@RequestMapping("/api/rag")
public class RagController {
private final RagService ragService;
public RagController(RagService ragService) {
this.ragService = ragService;
}
@PostMapping("/ask")
public ResponseEntity<ChatResponse> ask(@RequestBody AskRequest request) {
ChatResponse response = ragService.ask(request.getQuestion());
return ResponseEntity.ok(response);
}
}
核心在 RagService :
@Service
public class RagService {
private final ChatClient chatClient;
private final PgVectorStore vectorStore;
private final PromptTemplate ragPromptTemplate;
public RagService(ChatClient chatClient,
PgVectorStore vectorStore,
PromptTemplate ragPromptTemplate) {
this.chatClient = chatClient;
this.vectorStore = vectorStore;
this.ragPromptTemplate = ragPromptTemplate;
}
public ChatResponse ask(String question) {
// Step 1: 检索相关文档块
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(3) // 返回最相关的 3 个 chunk
.build()
);
// Step 2: 构建 RAG Prompt
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n")); // 用空行分隔不同 chunk
Prompt prompt = ragPromptTemplate.create(
Map.of("context", context, "question", question)
);
// Step 3: 调用大模型生成答案
ChatResponse chatResponse = chatClient.call(prompt);
// 附加检索详情(调试用,生产可删)
chatResponse.getMetadata().put("retrieved_chunks_count", relevantDocs.size());
chatResponse.getMetadata().put("retrieved_context_length", context.length());
return chatResponse;
}
}
PgVectorStore.similaritySearch() 方法背后,Spring AI 自动生成了这样的 SQL:
SELECT content, metadata,
1 - (embedding <=> ?) AS similarity
FROM document_chunk
WHERE 1 - (embedding <=> ?) > 0.2
ORDER BY embedding <=> ?
LIMIT 3;
? 是用户问题的 embedding 向量。 1 - (embedding <=> ?) 就是余弦相似度( <=> 是 PgVector 的余弦距离操作符,值越小越相似,所以用 1- 转成相似度)。
4.5 启动与验证:curl 测试全流程
启动应用后,先加载文档(用 Postman 或 curl):
curl -X POST http://localhost:8080/api/rag/load \
-H "Content-Type: application/json" \
-d '{"pdfPath":"/path/to/SpringBoot-Best-Practice.pdf"}'
然后提问:
curl -X POST http://localhost:8080/api/rag/ask \
-H "Content-Type: application/json" \
-d '{"question":"Spring Boot 如何配置多数据源?"}'
预期返回:
{
"id": "chatcmpl-...",
"content": "在 Spring Boot 中配置多数据源,需定义多个 DataSource Bean,并用 @Primary 标注主数据源...",
"metadata": {
"retrieved_chunks_count": 3,
"retrieved_context_length": 1248,
"model": "gpt-4o-mini-2024-07-18"
}
}
如果返回空或错误,按这个顺序排查:
- 检查 PostgreSQL 日志:
docker logs my-pg | grep "vector",确认扩展加载无报错; - 检查应用启动日志:搜索
PgVectorStore,确认 Bean 创建成功; - 手动执行 SQL:
SELECT * FROM document_chunk LIMIT 1;,确认表里有数据且embedding列非空; - 用
psql直接测试向量查询:SELECT content, 1-(embedding <=> '[0.1,0.2,...]') as sim FROM document_chunk ORDER BY sim DESC LIMIT 3;,验证 PG 层检索正常。
5. 常见问题与排查技巧实录:线上踩过的坑,都给你标好了
RAG 服务上线后,不会一帆风顺。我把过去半年在三个不同客户现场遇到的高频问题,按发生频率和致命程度排序,附上根因分析和实操解法。这些不是理论推演,是凌晨三点盯着 Grafana 看指标时,一行行日志扒出来的教训。
5.1 问题:检索结果完全不相关,相似度分数全是 0.01~0.05
现象 :用户问“订单超时怎么处理”,返回的却是“如何配置 Logback 日志级别”。 similarity-threshold 设为 0.2,但所有 similarity 字段都远低于此,导致 similaritySearch() 返回空列表。
根因分析 :90% 的情况是 embedding 模型与向量表维度不匹配 。比如你用 text-embedding-ada-002 (1536维),但表定义是 VECTOR(768) 。PostgreSQL 会默默把 1536 维向量截断成前 768 维,或者填充 0 补齐,导致向量空间扭曲,距离计算失效。
排查步骤 :
- 查看 Spring Boot 启动日志,搜索
EmbeddingModel,确认实际加载的模型名和维度; - 连上 PostgreSQL,执行
\d document_chunk,确认embedding列的类型是vector(1536); - 手动查一条数据:
SELECT id, array_length(embedding, 1) as dim FROM document_chunk LIMIT 1;,返回的dim必须等于 1536。
实操解法 :
- 如果维度不一致, 不要 ALTER COLUMN !
ALTER TABLE document_chunk ALTER COLUMN embedding TYPE vector(1536);会失败,因为 PostgreSQL 不允许直接修改vector类型的维度。 - 正确做法:新建临时表,迁移数据,再重命名:
CREATE TABLE document_chunk_new ( id BIGSERIAL PRIMARY KEY, document_id VARCHAR(64), chunk_index INTEGER, content TEXT, embedding VECTOR(1536), -- 新维度 created_at TIMESTAMP WITH TIME ZONE, metadata JSONB ); INSERT INTO document_chunk_new SELECT id, document_id, chunk_index, content, embedding::vector(1536), created_at, metadata FROM document_chunk; DROP TABLE document_chunk; ALTER TABLE document_chunk_new RENAME TO document_chunk; CREATE INDEX ... -- 重建索引
5.2 问题:向量查询慢,P95 延迟超过 2 秒
现象 : similaritySearch() 接口 P95 延迟飙升,Grafana 显示 pg_stat_statements 里该 SQL 的 total_time 占比超
更多推荐

所有评论(0)