ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于LangChain4j和pgvector打造网盘RAG知识库问答系统

基于LangChain4j和pgvector打造网盘RAG知识库问答系统 很多开发者做网盘项目时会把绝大部分精力放在文件上传、分片、下载、分享这些“存储功能”上。但如果你只是把数据存起来网盘的价值就还停留在“保管箱”层面。真正让一个网盘产品在 AI 时代产生质变的是让系统能理解文件内容并能回答用户关于文件内容的问题。CloudVault 就是这样一个综合项目它以网盘的文件管理为基础把 LangChain4j 的 RAG 问答链路、PostgreSQL pgvector 的向量检索、Redis 的缓存与实时通知组合成一个完整系统。用户上传的不是“死文件”而是可被检索、可被问答的知识片段。本文不打算只贴一堆碎片代码而是从架构选型、数据库设计、RAG 流程、实时通知到完整示例把这条链路讲透。如果你正在做网盘类项目、知识库系统或者想在 Java 技术栈里落地 RAG这篇文章适合你。1. 这篇文章真正要解决的问题先想一个问题网盘里存了 100 份文档你要找“去年项目评审会上关于数据库选型的结论”你怎么找传统网盘只能按文件名搜索你大概率得回忆文件名或者把所有文档下载下来逐个翻。这种事做过几次你就会意识到文件内容本身也是数据系统应该能索引它、理解它、回答关于它的问题。CloudVault 要解决的正是这个核心问题让网盘从“文件存储”升级为“个人知识库”。用户上传文档后系统自动完成解析、切片、向量化把内容存入 PostgreSQL pgvector用户在对话界面提问时系统先做向量检索把相关片段召回再交给大模型生成回答。这里有一个值得强调的判断RAG 并不是 Python 生态的专利。很多 Java 团队一提到知识库问答第一反应是“这东西要用 Python 写”。实际上LangChain4j 已经能覆盖绝大多数 RAG 工程化需求而且与 Spring Boot 项目的集成体验相当好。加上 pgvector 这样一个让 PostgreSQL 具备向量检索能力的插件Java 团队完全可以在不引入独立向量数据库的情况下做出一个可用的 RAG 系统。这篇文章会重点讲四件事CloudVault 的整体架构和核心选型理由。LangChain4j 在 RAG 流程中扮演什么角色它和 Python LangChain 的关系是什么。PostgreSQL pgvector 如何承担向量存储和检索任务。Redis 在网盘场景中如何同时解决缓存和实时通知问题。读完你不仅能看懂这条链路的原理还能照着把环境搭起来、把代码跑通。2. CloudVault 系统架构与核心技术选型2.1 系统模块划分CloudVault 虽然叫“仿百度网盘”但它并不是只做文件上传下载。从功能边界看系统可以拆成五个模块模块职责核心技术文件管理模块文件上传、下载、删除、列表、分享Spring Boot、对象存储或本地磁盘文档解析模块解析 PDF、Word、TXT提取文本切片Apache PDFBox、Apache POIRAG 问答模块向量化、检索、拼接 Prompt、调用大模型LangChain4j、pgvector实时通知模块任务进度、文件解析完成、问答流式输出Redis Pub/Sub、SSE用户与权限模块登录认证、文件权限隔离Spring Security、JWT从开发顺序看文件管理模块是地基RAG 问答模块是核心差异点实时通知模块是体验加分项。很多人做网盘项目时会忽略后两者实际上正是这两块让项目有了“AI 应用”的质感。2.2 RAG 核心处理链路CloudVault 的 RAG 问答链路可以简化成两条流程写入链路索引阶段用户上传文档 - 系统解析出纯文本 - 对文本切片 - 调用 Embedding 模型生成向量 - 把切片的原文和向量一起写入 pgvector。读取链路问答阶段用户提问 - 对问题生成向量 - 到 pgvector 中查找最相似的若干文档片段 - 把片段作为上下文拼接进 Prompt - 调用大模型生成回答 - 把回答返回给前端。这两条链路一个负责“建库”一个负责“检索和生成”。RAG 的工程质量很大程度取决于写入链路的解析和切片质量这比检索代码本身更容易成为短板。2.3 为什么选 PostgreSQL pgvector而不是单独的向量数据库过去两年很多项目一上 RAG 就直接引入 Milvus、Weaviate 或专门的向量数据库。但对于一个中小规模的网盘知识库系统这个决策需要重新思考。pgvector 的定位是“给 PostgreSQL 增加向量类型和向量索引能力”。它最大的优势在于你不需要为向量数据单独维护一套存储系统。文件元数据、用户数据、切片文本、向量数据都放在同一个 PostgreSQL 实例里事务一致性、备份恢复、权限管理沿用原来的方案即可。在向量数据量到达百万级之前pgvector 的 HNSW 索引性能完全够用。如果未来数据量真的涨到千万甚至亿级再考虑把向量部分迁移到 Milvus 也不晚。系统架构上LangChain4j 的 EmbeddingStore 接口本身就把存储实现抽象掉了你从 pgvector 切换到 Milvus 时并不需要重写业务代码。这一点在实际项目中非常重要。3. RAG 相关核心概念与 LangChain4j 的角色3.1 RAG 是什么为什么不是直接问大模型RAG 全称 Retrieval-Augmented Generation中文叫检索增强生成。通俗地说它是一种“开卷考试”模式不是让大模型凭记忆回答而是先从知识库里检索出相关资料带着资料一起让模型回答。为什么需要这套机制因为通用大模型的知识截止时间有限不可能自动知道你刚上传的内部文档。直接问模型“去年的数据库选型结论是什么”它大概率会编一个答案。但如果你先检索到相关文档片段再把片段放到 Prompt 里模型就能基于事实内容回答显著降低幻觉率。RAG 并不神秘它的核心就是四件事把文档拆成小块。为每一块生成向量。把用户的问题转成向量做相似度检索。把检索结果塞进 Prompt调用大模型回答。3.2 LangChain4j 解决了什么问题LangChain4j 是 LangChain 的 Java 版本思路实现它的目标是“让 Java 开发者也能用一套统一的接口开发大模型应用”。它把 RAG 链路中的组件抽象成了几个关键接口组件作用CloudVault 中的实现EmbeddingModel把文本转成向量对接 DashScope 或其他 OpenAI 兼容接口EmbeddingStore存储向量并执行相似度检索pgvector 存储实现EmbeddingStoreRetriever把检索逻辑封装成可复用组件问答服务中调用ChatLanguageModel调用大模型对话接口对接 OpenAI 兼容协议的大模型在具体使用中LangChain4j 提供的价值不在于“封装得多复杂”而在于把 RAG 链路的通用代码统一了。你不需要自己维护 HttpClient 调用、向量检索逻辑和 Prompt 拼接模板框架把这些集成好了业务代码只需要关心文档解析和问答业务。Python 生态里 LangChain 的调度和工具生态非常丰富但很多 Java 团队并不想在项目里混用 Python 微服务。LangChain4j 在 Java 项目内的衔接能力强得多类型安全、依赖管理和 Spring Boot 集成也更自然。3.3 向量到底是什么向量可以理解成一个能代表文本语义的“数字指纹”。比如“数据库选型”和“PostgreSQL 选择”这两句话字面不一样但如果它们被 Embedding 模型编码成向量后向量之间的夹角很小就说明语义相近。在 pgvector 中最常见的相似度计算方式是余弦距离。LangChain4j 在做检索时会先给用户问题生成向量然后通过 SQL 里的运算符或专门的检索方法找到与问题向量最接近的文档片段向量。Embedding 模型的选型是本项目的一个关键点。常见选择包括 OpenAI 的 text-embedding-3、阿里云 DashScope 的 text-embedding-v3 或 Qwen 的 Embedding 模型。国内场景下DashScope 的调用延迟和中文效果通常更合适。需要提醒的是不同模型的输出维度不同比如有的模型输出 1024 维有的输出 1536 维。这个维度必须和 pgvector 表里向量列声明一致否则 SQL 会直接报错。4. 环境准备PostgreSQL pgvector Redis4.1 用 Docker Compose 快速拉起依赖本项目最省心的环境准备方式是用 Docker Compose。建议准备如下docker-compose.ymlversion: 3.8 services: postgres: image: pgvector/pgvector:pg16 container_name: cloudvault-postgres environment: POSTGRES_DB: cloudvault POSTGRES_USER: cloudvault POSTGRES_PASSWORD: cloudvault ports: - 5432:5432 volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine container_name: cloudvault-redis ports: - 6379:6379 volumes: - redisdata:/data volumes: pgdata: redisdata:这里直接使用了pgvector/pgvector:pg16镜像它已经内置了 pgvector 插件省去手动编译安装的麻烦。如果团队规范不允许使用第三方镜像也可以使用官方postgres:16镜像在容器启动后手动执行CREATE EXTENSION vector但前提是镜像里已经安装了 pgvector 依赖。执行启动命令docker compose up -d4.2 验证 pgvector 是否可用进入 PostgreSQL 容器docker exec -it cloudvault-postgres psql -U cloudvault -d cloudvault执行CREATE EXTENSION IF NOT EXISTS vector; SELECT vector([1,2,3]) AS test_vector;如果能正常返回[1,2,3]说明插件已可用。这一步建议在项目初始化脚本里做成幂等操作避免重复建库时报错。4.3 Java 与依赖环境项目基于 Spring Boot 3 开发Java 版本建议使用 17 或更高。考虑到 Spring Boot 3 的底层是 Spring Framework 6需要 JDK 17 是硬性要求。如果本地没有 Docker或者 Windows 环境下想要直接安装 pgvector也可以选择在 PostgreSQL 官方站点下载匹配版本的安装包再单独下载对应 Windows 的 pgvector DLL 文件。但这种方式坑比较多版本不匹配会导致插件加载失败还是优先推荐 Docker。4.4 Redis 环境检查Redis 在这个项目里承担两个角色缓存和消息通道。启动后可以先用redis-cli ping确认连接正常docker exec -it cloudvault-redis redis-cli ping预期输出PONG。5. 数据库设计与向量索引5.1 核心表结构CloudVault 的数据库设计不复杂核心表包括用户表、文件表、文档切片表。用户表CREATE TABLE app_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT now() );文件表CREATE TABLE file_record ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES app_user(id), file_name VARCHAR(255) NOT NULL, file_type VARCHAR(32), file_size BIGINT, storage_path VARCHAR(512), status VARCHAR(16) DEFAULT UPLOADED, created_at TIMESTAMP DEFAULT now() );这里的status字段建议包含UPLOADED、PROCESSING、READY、FAILED几个取值用于标记文档异步解析进度。这个字段会和后面的 Redis 通知配合使用。5.2 文档切片表的向量字段设计文档切片表用于存 RAG 检索所需的文本和向量CREATE TABLE doc_segment ( id BIGSERIAL PRIMARY KEY, file_record_id BIGINT NOT NULL REFERENCES file_record(id), user_id BIGINT NOT NULL REFERENCES app_user(id), content TEXT NOT NULL, embedding VECTOR(1024), created_at TIMESTAMP DEFAULT now() );embedding列的维度必须和 Embedding 模型输出维度一致。如果模型输出 1536 维这里就写VECTOR(1536)。写错维度在插入数据时就会报错。5.3 HNSW 向量索引pgvector 支持 HNSW 和 IVFFlat 两种索引。在数据量几千到几万的场景下HNSW 的召回质量和查询延迟都更有优势而且不需要像 IVFFlat 那样先训练列表。CREATE INDEX idx_doc_segment_embedding ON doc_segment USING hnsw (embedding vector_cosine_ops);vector_cosine_ops表示使用余弦相似度作为距离度量。LangChain4j 的 pgvector 实现默认使用的就是余弦距离所以这里要和代码保持一致。在用户量较大时检索还必须带上user_id条件避免用户 A 检索到用户 B 的文档片段。SQL 上的做法是SELECT id, content, 1 - (embedding :query_vector) AS similarity FROM doc_segment WHERE user_id :userId ORDER BY embedding :query_vector LIMIT 5;这里的是 pgvector 提供的余弦距离运算符距离越小表示越相似。6. LangChain4j pgvector 文档解析与问答实现6.1 Maven 依赖在pom.xml中加入 LangChain4j 相关依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-pgvector/artifactId version${langchain4j.version}/version /dependency /dependencies这里把version写成了占位符原因是 LangChain4j 的版本迭代比较快不同小版本之间的 API 可能有差异。实际使用时建议以 Maven 中央仓库的当前稳定版本为准。6.2 配置文件与模型配置application.yml中除了常规的数据源配置重点关注 Embedding 模型和向量存储的配置spring: datasource: url: jdbc:postgresql://localhost:5432/cloudvault username: cloudvault password: cloudvault data: redis: host: localhost port: 6379 langchain4j: embedding-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} model-name: text-embedding-v3 chat-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: ${DASHSCOPE_API_KEY} model-name: qwen-plus使用环境变量注入 API Key 是一种更安全的做法避免把密钥提交到 Git 仓库。这里选择 DashScope 的 OpenAI 兼容接口是因为 Java 侧的 OpenAI 协议客户端可以直接复用不需要额外引入各家专用 SDK。6.3 构建 EmbeddingModel 和 EmbeddingStore在实际项目中可以把这些 Bean 配置放到配置类里package com.cloudvault.config; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.pgvector.PgVectorEmbeddingStore; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class LangChain4jConfig { Value(${langchain4j.embedding-model.base-url}) private String embeddingBaseUrl; Value(${langchain4j.embedding-model.api-key}) private String embeddingApiKey; Value(${langchain4j.embedding-model.model-name}) private String embeddingModelName; Bean public EmbeddingModel embeddingModel() { return OpenAiEmbeddingModel.builder() .baseUrl(embeddingBaseUrl) .apiKey(embeddingApiKey) .modelName(embeddingModelName) .build(); } Bean public EmbeddingStoreTextSegment embeddingStore() { return PgVectorEmbeddingStore.builder() .host(localhost) .port(5432) .database(cloudvault) .user(cloudvault) .password(cloudvault) .table(doc_segment) .dimension(1024) .build(); } }这里有个容易踩坑的点PgVectorEmbeddingStore的table(doc_segment)指向自建的表框架在启动时可能会尝试建表或检查表结构。如果表结构里已经有业务字段要避免字段命名冲突。比较稳妥的做法是让框架自动建表然后在同一张表上额外追加业务字段。但每个版本行为有差异建议先在一个测试表上验证。6.4 文档解析与索引写入当一个文件上传成功且类型是文档时系统会异步执行“解析 - 切片 - 向量化 - 入库”流程。package com.cloudvault.service; import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingStore; import org.springframework.stereotype.Service; import java.util.List; Service public class DocumentIndexService { private final EmbeddingModel embeddingModel; private final EmbeddingStoreTextSegment embeddingStore; public DocumentIndexService(EmbeddingModel embeddingModel, EmbeddingStoreTextSegment embeddingStore) { this.embeddingModel embeddingModel; this.embeddingStore embeddingStore; } public void indexDocument(Long fileRecordId, Long userId, String text) { // 1. 将文本包装为 LangChain4j 的 Document Document document Document.from(text); // 2. 切片每 500 个字符切一块重叠 50 个字符 ListTextSegment segments DocumentSplitters.recursive(500, 50) .split(document); // 3. 为每个切片设置元数据便于后续按用户和文件过滤 for (TextSegment segment : segments) { segment.metadata().put(fileRecordId, String.valueOf(fileRecordId)); segment.metadata().put(userId, String.valueOf(userId)); } // 4. 同时把切片和向量存入 pgvector embeddingStore.addAll(segments); } }这里的切片策略值得展开说。切片过大会导致检索到的大片段包含太多无关信息过小则可能截断关键语义。500 字符、50 重叠只是一个起点实际项目中应该根据文档类型调整。常见做法是技术文档、合同类按标题、章节切分先做段落识别。对话记录类按轮次切分。代码仓库类按文件和方法切分。LangChain4j 的DocumentSplitters.recursive使用递归字符分割优先在换行、句号等自然边界处切片比固定长度硬切的效果好一些。6.5 问答服务实现问答是用户可见的核心功能。先看代码package com.cloudvault.service; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingMatch; import dev.langchain4j.store.embedding.EmbeddingStore; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; Service public class ChatService { private final EmbeddingModel embeddingModel; private final EmbeddingStoreTextSegment embeddingStore; private final ChatLanguageModel chatLanguageModel; public ChatService(EmbeddingModel embeddingModel, EmbeddingStoreTextSegment embeddingStore, ChatLanguageModel chatLanguageModel) { this.embeddingModel embeddingModel; this.embeddingStore embeddingStore; this.chatLanguageModel chatLanguageModel; } public String answer(String question, Long userId) { // 1. 对用户问题生成向量 Embedding questionEmbedding embeddingModel.embed(question).content(); // 2. 从向量库召回最相关的 5 个片段 ListEmbeddingMatchTextSegment matches embeddingStore.findRelevant( questionEmbedding, 5); // 3. 过滤出当前用户的片段 String context matches.stream() .filter(match - { String owner match.embedded().metadata().get(userId); return String.valueOf(userId).equals(owner); }) .map(match - match.embedded().text()) .collect(Collectors.joining(\n\n)); // 4. 拼接 Prompt调用大模型 String prompt 你是 CloudVault 的文档助手。请根据以下资料回答问题。 如果资料中没有相关内容请明确回答“资料中未找到相关信息”。 资料 %s 用户问题%s .formatted(context, question); return chatLanguageModel.generate(prompt); } }这里有一个容易被忽视的问题embeddingStore.findRelevant返回的结果已经按相似度排序但业务上必须再做一次用户维度的过滤。我不能假设框架会自动感知 userId因为 pgvector 表里的向量是按全局维度建立索引的。更好的做法是让检索逻辑带上 SQL 条件直接WHERE user_id ?。在 LangChain4j 的PgVectorEmbeddingStore中可以通过设置 Metadata 字段来让查询自动过滤例如把 userId 放入元数据并配置过滤条件但要看具体版本的支持情况。6.6 文件类型解析文本类文件可以直接读取PDF 和 Word 需要额外解析工具。以 PDFBox 为例核心逻辑如下try (PDDocument document PDDocument.load(inputStream)) { PDFTextStripper stripper new PDFTextStripper(); return stripper.getText(document); }Word 文档使用 Apache POI 的XWPFDocument提取段落文本。这一步在项目里建议做成策略模式根据文件后缀选择不同的解析器。解析失败时不要阻塞主流程而是把文件状态标记为FAILED并通知用户。7. Redis 在 CloudVault 中的缓存与实时通知设计7.1 Redis 缓存文件列表与会话网盘系统中文件列表是高频查询接口。如果每次查询都走数据库用户量大时压力会很直接。CloudVault 的做法是文件列表数据缓存到 RedisKey 使用file:list:{userId}:{page}Value 使用 JSON。文件上传成功后主动删除对应用户的文件列表缓存。使用 Redis 的 Hash 结构保存热点文件的元信息避免反序列化整个 JSON。Spring Boot 中可以直接使用RedisTemplate操作public void cacheFileRecords(Long userId, ListFileRecord records) { String key file:list: userId; String json objectMapper.writeValueAsString(records); redisTemplate.opsForValue().set(key, json, Duration.ofMinutes(30)); }7.2 Redis Pub/Sub 实现实时通知“实时通知”在网盘场景里最典型的用途是用户上传一份大文档系统异步解析前端需要知道“解析完成了没”。一个简单的轮询也能实现但体验比较差。CloudVault 的做法是用 Redis Pub/Sub 做消息通道再通过 SSE 推送给前端。整体链路是用户上传文件接口立即返回文件状态为PROCESSING。异步任务解析文档并写入向量库。任务完成后服务端向 Redis 的file:notify频道发布一条消息。同一个 JVM 内的消息监听器收到消息通过 SseEmitter 推给对应用户。前端收到消息后刷新文件列表状态或提示“文档已可提问”。发送消息public void notifyFileReady(Long userId, Long fileId) { MapString, Object message new HashMap(); message.put(userId, userId); message.put(fileId, fileId); message.put(type, FILE_READY); message.put(timestamp, System.currentTimeMillis()); redisTemplate.convertAndSend(file:notify, objectMapper.writeValueAsString(message)); }监听消息Component public class FileNotifySubscriber { EventListener(ApplicationReadyEvent.class) public void subscribe() { // 这里通过 RedisMessageListenerContainer 监听 file:notify 频道 } }这里有一个架构层面的判断在多实例部署时每个业务实例都要订阅同一个 Redis 频道。这样用户连接的是实例 A只要实例 B 发布了消息实例 A 也能收到并推送。Redis Pub/Sub 天然支持多消费者模式因此这个设计不需要额外引入 RocketMQ 或 Kafka 就能覆盖网盘场景的通知需求。当然Redis Pub/Sub 有消息不持久化的特点。如果消息在发布时没有订阅者在线消息会丢失。对于“文件解析完成”这种可以靠状态查询补偿的通知来说这是可以接受的。如果业务对消息可靠性要求更高可以把 Redis Stream 当成轻量消息队列使用或者直接引入 RocketMQ。7.3 Redis 分布式锁防止重复索引异步任务处理文件时多个实例可能同时消费同一个文件的任务导致重复向量化。一个常见做法是用 Redis 分布式锁String lockKey file:lock: fileId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofMinutes(10)); if (Boolean.TRUE.equals(locked)) { try { documentIndexService.indexDocument(fileRecordId, userId, text); } finally { redisTemplate.delete(lockKey); } }使用setIfAbsent加过期时间避免任务崩溃后锁不释放。这是 Redis 实现分布式锁的最基础版本生产环境可以把释放锁操作改成 Lua 脚本保证“判断持有者 删除”的原子性。8. 完整示例代码串联前面几章已经把核心代码拆开讲了一遍这一节把整体流程串起来方便读者对照实现。8.1 文件上传接口RestController RequestMapping(/api/files) public class FileController { private final FileService fileService; public FileController(FileService fileService) { this.fileService fileService; } PostMapping public ResponseEntityFileRecord upload( RequestParam(file) MultipartFile file) { FileRecord record fileService.upload(file); return ResponseEntity.ok(record); } }8.2 文件服务异步解析Service public class FileService { private final FileRecordMapper fileRecordMapper; private final DocumentParser parser; private final DocumentIndexService indexService; private final FileNotifyService notifyService; Async public void processDocument(Long fileId, Long userId) { FileRecord record fileRecordMapper.findById(fileId); record.setStatus(PROCESSING); fileRecordMapper.update(record); try { String text parser.parse(record); indexService.indexDocument(fileId, userId, text); record.setStatus(READY); } catch (Exception e) { record.setStatus(FAILED); log.error(文档处理失败, e); } fileRecordMapper.update(record); notifyService.notifyFileReady(userId, fileId); } }8.3 问答接口RestController RequestMapping(/api/chat) public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService chatService; } PostMapping(/ask) public String ask(RequestBody AskRequest request, RequestAttribute Long userId) { return chatService.answer(request.getQuestion(), userId); } }这里需要解释一下RequestAttribute Long userId在实际项目中userId 来自 JWT 解析后的结果由 Spring Security 或拦截器写入请求属性。不要在接口里让前端直接传 userId否则会造成越权访问他人文件内容。8.4 SSE 通知接口RestController RequestMapping(/api/notify) public class NotifyController { private final SseService sseService; GetMapping(value /subscribe, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(RequestAttribute Long userId) { return sseService.createEmitter(userId); } }前端收到事件后触发文件列表刷新即可。这个 SSE 通道也可以复用到问答流式输出LangChain4j 的StreamingChatLanguageModel就支持按 token 流式返回配合 SSE 能实现打字机效果。9. 运行结果与效果验证9.1 启动顺序建议按以下顺序启动启动 Docker Compose 中的 PostgreSQL 和 Redis。执行数据库初始化脚本创建业务表和 pgvector 扩展。启动 Spring Boot 应用。用接口测试工具上传一份 PDF 或 TXT 文档。调用问答接口检验是否正确从刚上传的文档中回答问题。9.2 验证上传与解析上传一份名为技术选型报告.txt的文档内容包含项目评审会结论数据库选型采用 PostgreSQL原因是团队已有运维经验 且有 pgvector 可以支撑后续 AI 检索需求。调用问答接口curl -X POST http://localhost:8080/api/chat/ask \ -H Content-Type: application/json \ -d {question: 项目评审会关于数据库选型的结论是什么}预期回答中应该包含“PostgreSQL”这个关键词并且明确关联到文档内容而不是模型编造的其他数据库。如果回答结果与文档内容一致说明整条 RAG 链路已经跑通。如果回答里出现了文档中不存在的信息优先排查两步向量化是否成功直接查doc_segment表看文本片段是否入库。检索是否命中可以先把检索结果打印出来确认问答 Prompt 里确实拼入了相关片段。9.3 验证 Redis 通知上传大文件后订阅 SSE 接口curl -N http://localhost:8080/api/notify/subscribe当异步解析完成时这里应收到FILE_READY事件。如果收不到事件先检查 Redis 频道是否收到消息docker exec -it cloudvault-redis redis-cli SUBSCRIBE file:notify如果订阅者收到消息但 SSE 没有推给前端问题大概率出在 SseEmitter 的生命周期管理上比如客户端已经断开但服务端没有清理。10. 常见问题与排查思路问题现象可能原因排查方式解决方案pgvector 创建扩展失败插件未安装或 PostgreSQL 版本不匹配SELECT * FROM pg_available_extensions查看可用扩展使用 pgvector/pgvector:pg16 镜像或编译安装匹配版本的 pgvector向量表插入数据报维度错误Embedding 模型输出维度和表定义不一致打印模型返回向量的长度统一模型输出维度或修改表结构中的 VECTOR 维度问答回答内容与文档无关切片粒度不合理或检索 TopK 太小打印检索到的片段内容调整切片大小和重叠窗口适当增加召回数量检索结果为空文档解析后文本为空或向量未写入查看 file_record.status 和 doc_segment 表数据检查 PDF/Word 解析逻辑确认indexDocument是否被调用SSE 收不到通知Redis 频道消息未发布或 SseEmitter 已过期手动向 Redis 发布消息观察设置合理的 SseEmitter 超时时间发布时检查用户连接是否存在多实例下同一文件被重复解析缺少分布式锁查看日志是否多次执行 indexDocument使用 Redis 分布式锁或使用数据库唯一约束兜底中文 embedding 效果差模型对中文支持弱或切片把语义切碎抽样评估相似度检索结果切换中文友好的 embedding 模型调整切片策略这些问题是 RAG 项目里最常遇到的。很多“问答结果不对”的问题根子不在大模型而在前面的检索质量。检索不到或者检索到不相关内容大模型再强也答不对。11. 最佳实践与工程建议11.1 数据安全与权限隔离CloudVault 作为网盘系统文件内容的访问控制是底线。RAG 问答接口必须做到用户只能检索自己的文档片段。文件删除操作要有二次确认推荐引入回收站机制。生产环境下文件下载地址要使用短期有效的签名 URL而不是暴露内部存储路径。向量表里不能明文存储敏感信息如果文档包含机密内容要先过敏感信息过滤或脱敏。11.2 异步任务与事务边界文档解析、向量化是耗时操作不能放在用户请求线程里。建议通过Async或消息队列执行。注意事务边界向量写入 pgvector 和业务状态更新要尽量在一个事务里避免出现“业务状态标记为 READY但切片数据还没写完”的中间状态。如果任务失败需要具备重试机制。简单做法是启动一个定时任务扫描状态为PROCESSING且超过一定时间的文件标记为FAILED并重试。11.3 缓存策略Redis 缓存文件列表时必须考虑数据一致性文件上传、删除、改名时都要主动删除或刷新对应用户的缓存。缓存过期时间不宜过长30 分钟到 1 小时是比较合理的起点。热点文件内容可以缓存但要设置 TTL避免大文本长期占用内存。11.4 模型与 Prompt 调优RAG 系统的回答质量不是只靠模型Prompt 的质量同样关键。建议在 Prompt 中明确以下信息回答范围只能基于提供的资料。不知道就直说资料中没有相关内容时不要编造。引用来源指出是哪个文档、哪个片段支撑的回答便于用户核验。Embedding 模型和 Chat 模型可以分开选择。Embedding 模型要选中文效果好的Chat 模型可以按成本和效果灵活切换。11.5 数据备份与恢复PostgreSQL 做了向量数据备份方式依然和普通数据库一样docker exec cloudvault-postgres pg_dump -U cloudvault -d cloudvault -F c -f /tmp/cloudvault.dump不要因为向量数据是“新东西”就忽略常规运维手段。对生产环境而言备份、恢复演练、权限审计一个都不能少。11.6 从 pgvector 迁移到专业向量数据库当数据量增长到千万级、亿级或者需要更强的高可用和可扩展性时可以考虑迁移到 Milvus、Weaviate 等专业向量数据库。由于 LangChain4j 的EmbeddingStore接口隔离了存储实现迁移时只需要替换存储 Bean问答和索引服务代码可以复用。这也是推荐大家在工程上坚持面向接口编程的原因。12. 总结与后续学习方向CloudVault 这个项目真正的价值不是做了一个“能上传下载的网盘”而是演示了如何用一套 Java 技术栈把网盘的数据资产和 AI 能力打通。它解决了几个实际问题用 LangChain4j 在 Java 项目里落地 RAG不需要引入 Python 微服务。用 PostgreSQL pgvector 承担向量存储与检索不额外维护向量数据库。用 Redis 同时解决缓存和实时通知提升网盘系统的体验。如果顺着这个方向继续深入接下来值得研究的内容包括混合检索把向量检索和 PostgreSQL 全文检索合在一起提升关键词精确匹配的召回率。重排 Rerank对召回结果做二次排序进一步提升答案相关性。多轮对话记忆让问答接口能基于上下文追问而不是每次都从零开始。流式输出用 StreamingChatLanguageModel 结合 SSE 实现打字机效果体验会明显提升。生产级任务队列如果文件体量和并发任务很多可以把异步解析从Async升级为 RocketMQ 或 Kafka。建议先按本文把最小链路跑通再逐步叠加这些能力。RAG 系统的上限取决于数据的解析质量、切片策略和检索准确率这些靠看文章很难完全掌握只有在反复试验中才能找到适合自己业务的最佳参数。
返回列表