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 启动前完成扩展创建。

典型流程:

  1. 编写 init.sql
    -- /docker-entrypoint-initdb.d/init.sql
    CREATE DATABASE rag_app;
    \c rag_app
    CREATE EXTENSION IF NOT EXISTS vector;
    
  2. 构建自定义镜像(Dockerfile):
    FROM postgres:15
    COPY init.sql /docker-entrypoint-initdb.d/
    # 不需要 RUN apt-get install,ankane/pgvector 镜像已编译好
    
  3. 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 调用),它背后是多模型融合的解析引擎。

步骤:

  1. 下载 unstructured 服务(Docker):
    docker run -d -p 8000:8000 --rm -v $(pwd)/data:/data unstructured-io/unstructured-api:latest
    
  2. 编写解析服务:
    @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;
        }
    }
    
  3. 分块(Chunking)策略:不要用固定长度切分(如每 500 字符一刀)。Spring AI 内置 TokenTextSplitter ,按 token 数切分,更符合 LLM 处理习惯:
    @Bean
    public TextSplitter textSplitter() {
        return new TokenTextSplitter.Builder()
                .encodingName("cl100k_base") // OpenAI 模型的 tokenizer
                .maxTokensPerChunk(300)      // 每块最多 300 token
                .build();
    }
    
    为什么是 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,绰绰有余。

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"
  }
}

如果返回空或错误,按这个顺序排查:

  1. 检查 PostgreSQL 日志: docker logs my-pg | grep "vector" ,确认扩展加载无报错;
  2. 检查应用启动日志:搜索 PgVectorStore ,确认 Bean 创建成功;
  3. 手动执行 SQL: SELECT * FROM document_chunk LIMIT 1; ,确认表里有数据且 embedding 列非空;
  4. 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 补齐,导致向量空间扭曲,距离计算失效。

排查步骤

  1. 查看 Spring Boot 启动日志,搜索 EmbeddingModel ,确认实际加载的模型名和维度;
  2. 连上 PostgreSQL,执行 \d document_chunk ,确认 embedding 列的类型是 vector(1536)
  3. 手动查一条数据: 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 占比超

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐