Spring Boot + LangChain4j Day 4:知识库持久化,从“一重启就没“到“落地生根“

Spring Boot + LangChain4j Day 4:知识库持久化,从"一重启就没"到"落地生根"

🗄️ 读完本文你将收获:掌握 PGVector 持久化向量库的完整配置,学会 PDF 文档解析 + 动态上传入库,理解为什么生产环境的 RAG 知识库不能放内存。


一、问题:你会把自己写的文档存在内存里吗?

Day 3 我们实现了一个 Agent ------ AI 能调工具、能查知识库,看起来很完美。

但它藏了一个大坑:

复制代码
启动服务 → 加载文档 → 存入 InMemoryEmbeddingStore(JVM 内存)
                          ↓
                    服务重启(Ctrl+C)
                          ↓
                    数据全部消失 😱

InMemoryEmbeddingStore 就像草稿纸------一关机什么都没了。

正常的知识库应该是文件柜------存进去的东西,明天还在、下周还在、重启 N 次还在。

Day 4 要解决的就是这个:把草稿纸换成文件柜


二、Day 3 → Day 4 变化一览

复制代码
Day 3                               Day 4
─────────────────────────────────────────────────
InMemoryEmbeddingStore     →       PgVectorEmbeddingStore
只支持 TXT                 →       TXT + PDF + MD
classpath 固定文档         →       运行时上传 API
只读                      →       可动态增删(上传)

Agent 的逻辑没变,只换了"底座"。这也是 LangChain4j 最优雅的地方------你换存储引擎,Agent 接口一行不用改。


三、整体架构

复制代码
┌──────────────────────────────────────────────────┐
│              AgentController                      │
│       GET /agent/chat?message=...                 │
└────────────────────┬─────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────┐
│              Agent (AiServices)                   │
│                                                   │
│  ┌─────────────┐  ┌──────────┐  ┌─────────────┐  │
│  │ DeepSeek-V3 │  │  工具集   │  │  知识库(RAG) │  │
│  └─────────────┘  └──────────┘  └──────┬──────┘  │
│                                        │         │
│                              ContentRetriever     │
│                                        │         │
│                            ┌───────────┴───────┐  │
│                            │  PgVectorEmbedding│  │
│                            │  Store (★持久化)   │  │
│                            └──────────┬────────┘  │
│                                       │          │
│  ┌────────────────────────┐  ┌───────┴───────┐   │
│  │   DocumentController   │  │  PostgreSQL   │   │
│  │   POST /documents/     │  │  + PGVector   │   │
│  │        upload          │  │  扩展 (Docker) │   │
│  └────────────────────────┘  └───────────────┘   │
└──────────────────────────────────────────────────┘

两条数据流入路线:

  1. 启动预加载RagService 把 classpath docs 文件写入 PGVector(首次)
  2. 运行时上传DocumentControllerDocumentService → 解析 → 向量化 → 写入 PGVector

四、Docker 环境准备

docker环境准备的教程已经写好了,放在 day4/docs/windows-docker-wsl-setup.md

4.1 docker-compose.yml

yaml 复制代码
version: "3.9"
services:
  postgres:
    image: pgvector/pgvector:pg17    # PGVector 扩展已内置
    container_name: ai-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: aidb
      POSTGRES_USER: ai
      POSTGRES_PASSWORD: password
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    container_name: ai-redis
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

4.2 启动

bash 复制代码
docker compose up -d
docker ps | grep ai-postgres   # 确认在跑

⚠️ 关键pgvector/pgvector:pg17 镜像自带 PGVector 扩展,不需要手动 CREATE EXTENSION vector;


五、核心代码解析

5.1 Maven 依赖 ------ Day 4 新增的三个

xml 复制代码
<!-- PGVector 集成 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-pgvector</artifactId>
    <version>0.36.2</version>
</dependency>

<!-- PG 驱动 -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
</dependency>

<!-- PDF 解析 -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.3</version>
</dependency>

5.2 ChatModelConfig ------ 核心变化

java 复制代码
@Bean
public EmbeddingStore<TextSegment> embeddingStore() {
    return PgVectorEmbeddingStore.builder()
            .host("localhost")          // PG 地址
            .port(5432)
            .database("aidb")
            .user("ai")
            .password("password")
            .table("day4_rag_store")    // 向量表名
            .dimension(1024)            // bge-large-zh-v1.5 输出 1024 维
            .createTable(true)          // 首次启动自动建表
            .dropTableFirst(false)      // 不删旧数据
            .build();
}

这就是 Day 4 最核心的一行代码变化

对比 Day 3:

java 复制代码
// Day 3
return new InMemoryEmbeddingStore<>();

// Day 4
return PgVectorEmbeddingStore.builder()...build();

其余 ChatLanguageModelStreamingChatLanguageModelEmbeddingModel 三个 Bean 完全不变。

5.3 DocumentService ------ PDF 解析 + 动态入库

java 复制代码
@Service
@RequiredArgsConstructor
public class DocumentService {

    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;

    /**
     * 处理上传文件:解析文本 → 切片 → 向量化 → 入库
     */
    public int ingest(MultipartFile file, String title, Path saveDir)
            throws IOException {

        // 1. 根据文件类型提取文本
        String text = extractText(file);

        // 2. 切片(与 Day 3 策略一致)
        List<TextSegment> chunks = splitIntoChunks(title, text);

        // 3. 批量向量化 + 写入 PGVector
        var response = embeddingModel.embedAll(chunks);
        embeddingStore.addAll(response.content(), chunks);

        // 4. 保存原始文件到磁盘(便于追溯)
        saveOriginal(file, saveDir);
        return chunks.size();
    }

    /**
     * PDF 文本提取(Apache PDFBox 3.x)
     */
    private String extractPdfText(byte[] pdfBytes) throws IOException {
        try (PDDocument document = Loader.loadPDF(pdfBytes)) {
            PDFTextStripper stripper = new PDFTextStripper();
            stripper.setSortByPosition(true);  // 按坐标排序保持阅读顺序
            return stripper.getText(document);
        }
    }
}

PDFBox 为什么需要?

PDF 是二进制格式,不能当文本直接读。用记事本打开 PDF 看到的是乱码。PDFBox 负责解析 PDF 内部结构,提取出人类可读的文字。

⚠️ PDFBox 3.x 变化Loader.loadPDF() 替代了旧版 PDDocument.load()。如果搜到旧教程用 PDDocument.load(),那是 PDFBox 2.x 的写法,3.x 编译不通过。

5.4 DocumentController ------ 上传接口

java 复制代码
@RestController
@RequestMapping("/documents")
@RequiredArgsConstructor
public class DocumentController {

    private final DocumentService documentService;

    @PostMapping("/upload")
    public Map<String, Object> upload(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "title", required = false) String title) {

        int chunks = documentService.ingest(file, docTitle, Path.of(uploadDir));

        return Map.of(
            "success", true,
            "title", docTitle,
            "chunks", chunks,
            "message", "文档入库成功,共 " + chunks + " 个切片"
        );
    }
}

六、测试验证

6.1 上传 文档

6.2 验证知识库已生效

6.3 关键验证:重启后数据还在!

6.4 直接查数据库验证

bash 复制代码
docker exec -it ai-postgres psql -U ai -d aidb

# 查看向量表结构
\d day4_rag_store

# 查看数据条数
SELECT COUNT(*) FROM day4_rag_store;

# 查看某条记录的文本
SELECT text FROM day4_rag_store LIMIT 1;

七、为什么 PGVector 比 InMemory 好?

维度 InMemory PGVector
持久化 ❌ 重启丢失 ✅ 数据落地
并发 单机内存 数据库级 ACID
备份 pg_dump / WAL 归档
扩容 受限于 JVM 堆大小 磁盘空间
开发体验 零配置,开箱即用 需要 Docker
适合场景 PoC / 本地调试 生产环境

生产环境铁律:敏感数据不能靠内存。PGVector 让你从 Demo 到上线只改一行配置。


八、踩坑实录

坑 1:PGVector 扩展未启用

现象 :启动报 type "vector" does not exist

原因:普通 PostgreSQL 镜像没有安装 PGVector 扩展。

解决 :使用 pgvector/pgvector:pg17 镜像,扩展已内置。或者手动执行:

sql 复制代码
CREATE EXTENSION IF NOT EXISTS vector;

坑 2:dimension 不匹配

现象:入库报错,提示向量维度不一致。

原因 :配置文件写的 embedding-model-name 对应的模型输出维度与 dimension(1024) 不匹配。

解决BAAI/bge-large-zh-v1.5 输出 1024 维。如果换模型(如 bge-small-zh 是 512 维),对应改 dimension 参数,并删旧表重建。

坑 3:PDF 中文乱码

现象:PDF 解析出的中文是乱码。

原因:部分 PDF 使用了非标准字体编码。

解决 :PDFBox 的 PDFTextStripper 对大多数标准 PDF 处理良好。复杂排版建议加 OCR 流程(不在本文范围)。

坑 4:忘记先把 Docker 跑起来

现象 :启动服务报 Connection refused: localhost:5432

解决 :先 docker compose up -d,等 docker ps 确认容器 healthy 再启动服务。


九、总结

Day 知识库状态 能做什么
Day 3 内存中,重启丢失 演示 Agent + RAG
Day 4 PostgreSQL 持久化 生产可用,重启保留,动态上传

Day 4 最重要的三件事

  1. Persistence > Memory:数据要落地,不能靠运气
  2. Dynamic > Static:知识库要在运行时活起来,不能只靠 classpath
  3. Abstraction wins:LangChain4j 的接口设计让你换存储引擎只改一行配置

📦 完整代码:https://gitee.com/jackXUYY/java-ai-learn/tree/master/day4

🐳 PGVector 镜像:https://hub.docker.com/r/pgvector/pgvector

📚 LangChain4j PGVector 文档:https://docs.langchain4j.dev/integrations/embedding-stores/pgvector