如果把普通网盘比作一个仓库,那 CloudVault 想做的就是在仓库里配一位“随叫随到的图书管理员”。你不需要记住文件叫什么名字,只需要用自然语言提问,就能从自己上传的文档里拿到答案。本文将完整拆解 CloudVault 的核心实现,覆盖 LangChain4j + RAG 文档问答、PostgreSQL + pgvector 向量检索、Redis 缓存与实时通知三大部分,包含可直接复制的代码、配置和踩坑记录。
文章适合具备 Spring Boot 基础的后端开发者,也适合正在调研 RAG 工程化落地的同学。如果你想知道“RAG 知识库到底怎么和业务系统集成”“pgvector 怎么装、怎么建索引、怎么写相似度查询”“Redis 在网盘系统里除了做缓存还能做什么”,那这篇内容能给你一份相对完整的参考答案。
1. 背景与核心概念
1.1 为什么做“AI 网盘”而不是普通网盘
传统网盘解决的是“文件存储与下载”问题,核心能力是上传、下载、分享、权限管理。但一个真实业务里,文件被存进去之后,真正高频的场景其实是“找内容”。
比如产品经理上传了一份 20 页的需求文档,第二天想确认“v2.3 版本中登录页的按钮文案是什么”,他需要下载文档、翻页、搜索关键词,效率很低。更麻烦的是,如果资料分散在几十个文档里,靠人力整理会非常痛苦。
CloudVault 的思路是:在网盘文件存储之上叠加一层 AI 认知能力。文件上传后自动进行解析、切片、向量化,用户直接用自然语言提问,系统从向量库中召回相关片段,再交给大模型生成答案。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)的典型应用。
1.2 RAG 在网盘场景中的价值
RAG 的核心思想是“先检索,再生成”。它不会让大模型凭空回答,而是先从私有文档库中检索出和问题最相关的内容片段,把这些片段拼进提示词,再让大模型基于这些内容生成答案。
这样做有三个非常明显的好处:
- 数据私密可控。文档不需要上传给外部模型训练,你可以使用本地部署的 Embedding 模型和对话模型,完成私有化知识库建设。
- 答案有据可查。通过向量检索拿到的片段可以展示给用户,回答的每一步都能溯源。
- 知识实时更新。只要往知识库里新增文档,问答能力立刻覆盖新知识,不需要重新训练模型。
在 CloudVault 的语境里,RAG 解决了“网盘文件太多、用户找不到内容”的核心痛点。
1.3 核心术语:Embedding、向量数据库、pgvector、Redis
这几个概念在后文会反复出现,先统一说明:
- Embedding(向量化):把一段文本转换成一串浮点数数组。意思相近的文本,向量在高维空间里的距离更近。
- 向量数据库:专门存储和检索向量的数据库,支持余弦相似度、欧氏距离等最近邻算法。
- pgvector:PostgreSQL 的扩展插件。它让原生的 PostgreSQL 具备了向量存储和相似度检索能力,不用额外引入独立的向量数据库组件。
- Redis:开源的内存键值存储系统。在 CloudVault 里承担缓存、分布式锁、Pub/Sub 消息通知多重职责。
2. 整体架构与模块设计
2.1 系统模块拆分
CloudVault 按业务边界分为五个模块:
| 模块 | 职责 | 关键技术点 |
|---|---|---|
| 文件模块 | 上传、下载、秒传、分片、元数据管理 | Spring Boot、Redis 缓存、分布式锁 |
| AI 问答模块 | 文档解析、切片、向量化、检索、生成回答 | LangChain4j、pgvector、Ollama |
| 实时通知模块 | 上传/索引状态向前端推送 | Redis Pub/Sub、WebSocket |
| 用户与权限模块 | 登录鉴权、文件归属校验 | Spring Security、JWT |
| 存储模块 | 文件对象存储,本地磁盘或 MinIO | MinIO / 本地文件系统 |
在实际交付中,文件存储可以直接用本地磁盘,也可以替换成 MinIO、阿里云 OSS 等对象存储服务。重点是文件存储方案与 AI 索引流程解耦。
2.2 技术选型对照表
| 组件 | 选型 | 作用 |
|---|---|---|
| 开发语言 | Java 17 | Spring Boot 3.x 的基础要求 |
| 主框架 | Spring Boot 3.x | Web API、依赖注入、异步任务 |
| AI 编排框架 | LangChain4j | 统一封装文档加载、切片、Embedding、模型调用 |
| 向量数据库 | PostgreSQL 16 + pgvector | 存储文档切片向量,支持相似度检索 |
| 缓存与消息 | Redis 7.x | 文件元数据缓存、分布式锁、Pub/Sub 通知 |
| Embedding 模型 | Ollama 本地部署的 qwen 系列 | 文本向量化 |
| 对话模型 | Ollama 本地部署的 qwen2.5 | 基于检索结果生成答案 |
| 构建工具 | Maven | 依赖管理 |
2.3 核心数据流:从文件上传到 AI 问答
整个系统的核心流程可以拆成两条链路。
链路一:文件上传与 AI 索引
文件上传成功后,判断文件类型是否支持解析(TXT、PDF、Word、Markdown 等)。如果支持,系统从文件中抽取文本,按固定长度切片,生成向量,写入 pgvector 表。这一步建议采用异步处理,避免阻塞上传接口。
链路二:文档问答
用户输入问题,系统对问题做向量化,从 pgvector 中召回最相似的 TopK 文档片段,拼装提示词,调用大模型生成答案。最终把答案和引用片段一起返回给前端。
3. 环境准备与版本说明
3.1 环境清单
版本需要根据你的实际环境调整,本文以常见组合为例:
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17+ | Spring Boot 3.x 必须使用 JDK 17 及以上 |
| Spring Boot | 3.2.x | 本文示例基于 Spring Boot 3 |
| LangChain4j | 0.35.x | 版本更新较快,以官网最新稳定版为准 |
| PostgreSQL | 16+ | 建议使用官方或 pgvector 镜像 |
| pgvector | 0.7.x | 支持 HNSW 索引 |
| Redis | 7.x | 推荐 7.0 以上版本 |
| Ollama | 最新版 | 本地运行 embedding 与聊天模型 |
执行下面的命令确认基础环境:
java -version mvn -version docker --version3.2 项目目录结构
为了方便后文阅读,这里先给出一个最小项目结构:
cloudvault/ ├── pom.xml ├── docker-compose.yml ├── src/main/java/com/cloudvault/ │ ├── CloudVaultApplication.java │ ├── file/ │ │ ├── controller/FileController.java │ │ ├── service/FileStorageService.java │ │ └── service/FileMetaService.java │ ├── ai/ │ │ ├── controller/ChatController.java │ │ ├── service/KnowledgeBaseService.java │ │ ├── service/RagChatService.java │ │ └── repository/VectorSearchRepository.java │ └── notify/ │ ├── config/RedisPubSubConfig.java │ ├── config/WebSocketConfig.java │ ├── handler/RedisNotifyHandler.java │ └── service/NotifyService.java └── src/main/resources/ └── application.yml后文代码均按此目录结构组织。
4. PostgreSQL + pgvector 环境搭建
pgvector 是 RAG 链路中的关键基础设施。下面分别介绍 Docker Compose 一键部署和 Windows 本地安装两种方式。
4.1 Docker Compose 一键启动 PostgreSQL 与 Redis
在项目根目录创建docker-compose.yml:
version: "3.8" services: postgres: image: pgvector/pgvector:pg16 container_name: cloudvault-postgres environment: POSTGRES_USER: cloudvault POSTGRES_PASSWORD: cloudvault123 POSTGRES_DB: cloudvault ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U cloudvault"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7.2-alpine container_name: cloudvault-redis ports: - "6379:6379" command: redis-server --appendonly yes --requirepass cloudvault123 volumes: - redisdata:/data volumes: pgdata: redisdata:这里选择了pgvector/pgvector:pg16镜像,它已经预装了 pgvector 插件。Redis 镜像启动时通过--requirepass设置了访问密码,避免裸奔。
启动服务:
docker compose up -d验证 PostgreSQL 与 Redis 是否正常:
docker ps docker exec -it cloudvault-postgres psql -U cloudvault -d cloudvault -c "SELECT version();" docker exec -it cloudvault-redis redis-cli -a cloudvault123 ping需要注意:--requirepass方式仅适合开发环境。生产环境更推荐通过 Redis ACL 配置独立用户和权限。
4.2 Windows 安装 pgvector 插件
如果后端服务直接在 Windows 上运行,需要为本机 PostgreSQL 安装 pgvector。各大平台安装方式不同,Windows 下通常从 pgvector 官方 GitHub Releases 页面下载与 PostgreSQL 版本匹配的预编译安装包。
安装完成后,在 PostgreSQL 的share/extension目录下应该能看到vector.control文件。如果看不到,说明扩展文件没有复制成功。
Windows 安装常见的坑是 PostgreSQL 版本与 pgvector 版本不匹配。例如 PostgreSQL 16 必须选择pgvector-pg16对应的安装包。
4.3 创建向量表和 HNSW 索引
连接数据库,启用扩展:
CREATE EXTENSION IF NOT EXISTS vector;创建文档表,注意embedding VECTOR(1024)的维度必须和后续所用 Embedding 模型的输出维度一致:
CREATE TABLE file_documents ( id BIGSERIAL PRIMARY KEY, file_id VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, owner_id VARCHAR(64) NOT NULL, chunk_index INT NOT NULL, chunk_text TEXT NOT NULL, embedding VECTOR(1024), created_at TIMESTAMP DEFAULT NOW() );为余弦相似度检索创建 HNSW 索引:
CREATE INDEX idx_file_documents_embedding ON file_documents USING hnsw (embedding vector_cosine_ops); CREATE INDEX idx_file_documents_file_id ON file_documents (file_id);HNSW(Hierarchical Navigable Small World)索引适合大数据量下的近似最近邻检索。小规模测试时也可以使用 IVFFlat 索引,但 HNSW 在查询速度和构建复杂度上更均衡。pgvector 从 0.5.0 版本开始支持 HNSW,低版本需要先升级。
字段说明:
file_id:关联网盘文件表中的文件记录。owner_id:文件归属用户,用于问答时隔离不同用户的知识库。chunk_index:切片序号,方便定位原文。chunk_text:原始文本片段,在 RAG 中会作为上下文传给大模型。embedding:文本片段对应的向量。
5. 基于 LangChain4j 的 RAG 文档问答实现
5.1 为什么使用 LangChain4j
LangChain4j 是 Java 生态中面向大模型应用的编排框架,类比 Python 生态的 LangChain。它提供了一套统一 API,把文档加载、文本切片、Embedding、向量存储、模型调用等组件串联起来。对于 Java 技术栈的团队,选择 LangChain4j 可以直接复用现有工程能力,不必引入 Python 服务。
5.2 添加 Maven 依赖
在pom.xml中加入以下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-pgvector</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-ollama</artifactId> <version>0.35.0</version> </dependency>版本说明:LangChain4j 发布节奏较快,上述 0.35.0 是示例版本,请以官方最新稳定版为准。不同版本之间的配置前缀和 API 可能略有调整。
5.3 配置模型与向量存储
在生产项目中,强烈建议先通过 Ollama 拉取模型:
ollama pull qwen2.5:7b然后在application.yml中配置数据源、Redis 和 LangChain4j:
spring: datasource: url: jdbc:postgresql://localhost:5432/cloudvault username: cloudvault password: cloudvault123 driver-class-name: org.postgresql.Driver data: redis: host: localhost port: 6379 password: cloudvault123 langchain4j: ollama: base-url: http://localhost:11434 chat-model: model-name: qwen2.5:7b temperature: 0.2 embedding-model: model-name: qwen2.5:7b配置PgVectorEmbeddingStore的 Bean:
// 文件路径:src/main/java/com/cloudvault/ai/config/VectorStoreConfig.java @Configuration public class VectorStoreConfig { @Value("${spring.datasource.url}") private String dbUrl; @Value("${spring.datasource.username}") private String dbUser; @Value("${spring.datasource.password}") private String dbPassword; @Value("${ai.embedding.dimension:1024}") private Integer dimension; @Bean public PgVectorEmbeddingStore pgVectorEmbeddingStore() { return PgVectorEmbeddingStore.builder() .host("localhost") .port(5432) .database("cloudvault") .user(dbUser) .password(dbPassword) .table("file_documents") .dimension(dimension) .build(); } }需要注意:向量维度必须与 Embedding 模型输出维度一致。如果 Ollama 中 embedding 模型输出维度是 768,则VECTOR(768)和dimension(768)需要同步修改,否则会报维度不匹配错误。
5.4 文档解析、切分与向量化
文档进入知识库要经过四个步骤:读取文件、抽取文本、切片、生成向量并入库。
// 文件路径:src/main/java/com/cloudvault/ai/service/KnowledgeBaseService.java @Service @RequiredArgsConstructor @Slf4j public class KnowledgeBaseService { private final EmbeddingModel embeddingModel; private final PgVectorEmbeddingStore embeddingStore; public void ingestFile(MultipartFile file, String fileId, String ownerId) { // 1. 读取文件内容,这里以纯文本为例 // 实际项目中 PDF、Word 需要引入对应的解析库 String content; try { content = new String(file.getBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("读取文件失败", e); } // 2. 将内容封装为 LangChain4j 的 Document Document document = Document.from(content); // 3. 切片:每段 500 字符,重叠 100 字符 DocumentSplitter splitter = DocumentSplitters.recursive(500, 100); List<TextSegment> segments = splitter.split(document); // 4. 批量向量化 List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); // 5. 写入向量库 for (int i = 0; i < segments.size(); i++) { TextSegment segment = segments.get(i); // 通过 metadata 保存文件归属信息,便于后续权限隔离 segment.metadata().put("fileId", fileId); segment.metadata().put("ownerId", ownerId); segment.metadata().put("fileName", file.getOriginalFilename()); segment.metadata().put("chunkIndex", String.valueOf(i)); embeddingStore.add(segment, embeddings.get(i)); } log.info("文件入库完成, fileId={}, chunks={}", fileId, segments.size()); } }几个关键点解释一下:
- 切片大小:500 字符是一个相对均衡的默认值。切太大会导致检索精度下降,切太细容易丢失上下文语义。
- 重叠区间:相邻切片保留 100 字符重叠,避免一个完整语义被拦腰切断。
- Metadata 信息:向量库中除了向量本身,还保存了文件与用户信息。这为后续“只检索自己的文档”提供了基础。
上面示例使用的是纯文本情况。如果支持 PDF 或 Word,需要额外引入 Apache PDFBox、Apache POI 等库,先把二进制内容抽取成文本,再做后续处理。
5.5 自定义 SQL 向量检索
LangChain4j 自带的embeddingStore.findRelevant()可以完成基础检索,但在真实业务中往往需要组合条件过滤,比如按owner_id隔离用户、按file_id限定文档范围。此时更推荐直接使用 Spring JDBC 执行原生 SQL。
// 文件路径:src/main/java/com/cloudvault/ai/repository/VectorSearchRepository.java @Repository @RequiredArgsConstructor public class VectorSearchRepository { private final JdbcTemplate jdbcTemplate; public List<DocumentChunk> searchSimilar( String ownerId, float[] queryVector, int topK) { // 将 float 数组转为 PostgreSQL 向量字面量 String vectorLiteral = Arrays.stream(queryVector) .mapToObj(v -> String.format(Locale.ROOT, "%.6f", v)) .collect(Collectors.joining(",")); String sql = """ SELECT file_id, file_name, chunk_index, chunk_text, 1 - (embedding <=> ?::vector) AS similarity FROM file_documents WHERE owner_id = ? ORDER BY embedding <=> ?::vector LIMIT ? """; return jdbcTemplate.query( sql, new Object[]{vectorLiteral, ownerId, vectorLiteral, topK}, (rs, rowNum) -> new DocumentChunk( rs.getString("file_id"), rs.getString("file_name"), rs.getInt("chunk_index"), rs.getString("chunk_text"), rs.getDouble("similarity") ) ); } }这里使用的是 pgvector 的余弦距离操作符<=>。返回结果中1 - distance就是余弦相似度,值越大表示向量越接近。通过WHERE owner_id = ?实现了知识库的按用户隔离,避免用户 A 问到用户 B 的文档内容。
如果希望在整个系统里做更复杂的过滤,例如同时限定目录、文件类型,可以继续在 WHERE 子句中追加条件,这是原生 SQL 方案的最大优势。
5.6 问答接口完整实现
问答服务的核心逻辑分四步:向量化用户问题、从知识库检索候选片段、组装带上下文的提示词、调用大模型生成回答。
// 文件路径:src/main/java/com/cloudvault/ai/service/RagChatService.java @Service @RequiredArgsConstructor @Slf4j public class RagChatService { private final EmbeddingModel embeddingModel; private final ChatLanguageModel chatModel; private final VectorSearchRepository vectorSearchRepository; public RagAnswer answer(String ownerId, String question) { // 1. 用户问题向量化 Embedding questionEmbedding = embeddingModel.embed(question).content(); // 2. 从向量库中检索 Top5 相关片段 List<DocumentChunk> chunks = vectorSearchRepository.searchSimilar( ownerId, questionEmbedding.vector(), 5 ); if (chunks.isEmpty()) { return RagAnswer.fail("知识库中没有找到相关内容"); } // 3. 组装上下文 StringBuilder context = new StringBuilder(); for (DocumentChunk chunk : chunks) { context.append("【文档:") .append(chunk.getFileName()) .append("】\n") .append(chunk.getChunkText()) .append("\n---\n"); } // 4. 构造提示词并调用大模型 String prompt = """ 你是一个网盘智能助手。请严格根据下面提供的参考资料回答用户问题。 如果参考资料不足以回答,请直接说明“知识库中未找到相关内容”,不要编造答案。 参考资料: %s 用户问题:%s 请用简洁、准确的中文回答。 """.formatted(context.toString(), question); String answer = chatModel.generate(prompt); // 5. 返回答案和引用片段,方便前端展示出处 return RagAnswer.success(answer, chunks); } }在提示词中明确要求“不要编造答案”,是 RAG 项目里最重要的工程经验之一。大模型在缺乏信息时倾向于“合理化”猜测,必须通过提示词约束,同时在后端增加引用片段展示能力,让用户能核查答案来源。
ChatController提供 HTTP 接口:
// 文件路径:src/main/java/com/cloudvault/ai/controller/ChatController.java @RestController @RequestMapping("/api/chat") @RequiredArgsConstructor public class ChatController { private final RagChatService ragChatService; @PostMapping("/ask") public ApiResult<RagAnswer> ask( @RequestBody AskRequest request, @RequestHeader("X-User-Id") String userId) { return ApiResult.success(ragChatService.answer(userId, request.getQuestion())); } }请求体结构通过一个简单的 DTO 封装:
@Data public class AskRequest { private String question; }至此,一条完整的 RAG 问答链路已经打通:上传文件时解析入库,用户提问时检索召回,大模型生成答案。
6. Redis 在系统中的应用与实时通知
Redis 在 CloudVault 中不是配角。除了常规缓存,它还承担分布式锁和实时通知两条关键链路。
6.1 Redis 缓存文件元数据
文件元数据是高频访问数据。用户打开网盘首页、搜索文件、下载校验时都会查询。如果每次都查 PostgreSQL,接口压力会很大。
// 文件路径:src/main/java/com/cloudvault/file/service/FileMetaService.java @Service @RequiredArgsConstructor @Slf4j public class FileMetaService { private static final long CACHE_TTL_MINUTES = 30; private final StringRedisTemplate redisTemplate; private final FileMetaMapper fileMetaMapper; public FileMeta getFileMeta(String fileId) { // 1. 先查询缓存 String json = redisTemplate.opsForValue().get("file:meta:" + fileId); if (json != null) { return JSON.parseObject(json, FileMeta.class); } // 2. 缓存未命中则访问数据库 FileMeta meta = fileMetaMapper.selectById(fileId); if (meta != null) { redisTemplate.opsForValue().set( "file:meta:" + fileId, JSON.toJSONString(meta), CACHE_TTL_MINUTES, TimeUnit.MINUTES ); } return meta; } }当文件发生变化时需要主动删除缓存:
public void evictFileMeta(String fileId) { redisTemplate.delete("file:meta:" + fileId); }对于文件元数据这类读多写少的数据,Cache-Aside 模式足够。注意,即使 Redis 缓存了数据,也必须有 TTL 兜底,防止缓存与数据库长期不一致。
6.2 Redis 分布式锁实现秒传
秒传是网盘系统的高频需求。用户上传文件时,系统先计算文件 MD5。如果该文件已经存在,直接复用已有文件记录,避免重复上传。
这里有一个并发问题:同一个 MD5 可能被多个用户同时上传,如果都发现“文件不存在”,就会重复写入。解决办法是给 MD5 加一个分布式锁。
// 文件路径:src/main/java/com/cloudvault/file/service/FileStorageService.java @Service @RequiredArgsConstructor @Slf4j public class FileStorageService { private final StringRedisTemplate redisTemplate; private final FileMetaMapper fileMetaMapper; public UploadResult uploadByMd5(String md5, MultipartFile file, String ownerId) { String lockKey = "lock:upload:" + md5; // 1. 尝试加锁,2 分钟自动过期,避免异常情况下死锁 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 2, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(locked)) { // 其他线程正在处理同一文件,可以稍后重试或直接等待 throw new BizException("文件正在处理中,请稍后重试"); } try { // 2. 加锁后再次检查文件是否存在(双重检查) FileMeta existing = fileMetaMapper.selectByMd5(md5); if (existing != null) { return UploadResult.fastSuccess(existing.getFileId()); } // 3. 真正保存文件并写入元数据 FileMeta meta = saveFileToStorage(file, md5, ownerId); return UploadResult.success(meta.getFileId()); } finally { // 4. 释放锁 redisTemplate.delete(lockKey); } } }关于分布式锁,有两点需要重点说明:
setIfAbsent(SETNX)加上过期时间必须在一个命令里完成,否则加锁和设置过期时间之间发生宕机,锁永远不会释放。- 业务执行时间要小于锁过期时间。上传大文件耗时长,如果锁提前过期,其他请求就会并发进入。更稳妥的方案是使用 Redisson 的看门狗机制自动续期。
6.3 Redis Pub/Sub + WebSocket 实时通知
实时通知的典型场景是:用户上传一份大文档,AI 索引可能需要几十秒甚至几分钟。如果网页一直停留在一个等待页面,体验很差。更好的做法是让用户先做其他事,AI 索引完成后,系统主动推送“索引完成”通知。
CloudVault 采用 Redis Pub/Sub + WebSocket 组合。
第一步:配置 WebSocket
// 文件路径:src/main/java/com/cloudvault/notify/config/WebSocketConfig.java @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); registry.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } }前端订阅指定用户的频道:
const socket = new SockJS('/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, function () { stompClient.subscribe('/topic/user/' + userId, function (response) { const notify = JSON.parse(response.body); console.log('收到通知:', notify); // 根据 notify.type 刷新文件列表或上传状态 }); });第二步:发布 Redis 消息
// 文件路径:src/main/java/com/cloudvault/notify/service/NotifyService.java @Service @RequiredArgsConstructor @Slf4j public class NotifyService { private final StringRedisTemplate redisTemplate; public void notifyUser(String userId, String eventType, String message) { String channel = "cloudvault:notify:" + userId; String payload = String.format( "{\"type\":\"%s\",\"message\":\"%s\",\"timestamp\":\"%s\"}", eventType, message, System.currentTimeMillis() ); redisTemplate.convertAndSend(channel, payload); log.info("发布通知: userId={}, event={}", userId, eventType); } }第三步:监听 Redis 消息并转发给 WebSocket
// 文件路径:src/main/java/com/cloudvault/notify/config/RedisPubSubConfig.java @Configuration public class RedisPubSubConfig { @Bean public RedisMessageListenerContainer container( RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listenerAdapter, new PatternTopic("cloudvault:notify:*")); return container; } @Bean public MessageListenerAdapter listenerAdapter(RedisNotifyHandler handler) { return new MessageListenerAdapter(handler); } }// 文件路径:src/main/java/com/cloudvault/notify/handler/RedisNotifyHandler.java @Component @Slf4j public class RedisNotifyHandler implements MessageListener { private final SimpMessagingTemplate messagingTemplate; public RedisNotifyHandler(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate = messagingTemplate; } @Override public void onMessage(Message message, byte[] pattern) { String channel = new String(message.getChannel()); String payload = new String(message.getBody()); log.info("收到Redis消息: channel={}, payload={}", channel, payload); String userId = channel.substring(channel.lastIndexOf(":") + 1); messagingTemplate.convertAndSend("/topic/user/" + userId, payload); } }这套机制的核心价值在于:Spring Boot 多个实例都能收到 Redis Pub/Sub 消息,但只有持有用户 WebSocket 连接的实例负责下发。如果以后把系统扩展成多实例部署,Redis Pub/Sub 天然支持广播,不需要额外引入消息队列。
7. 常见问题与排查思路
下面是 CloudVault 开发过程中最可能遇到的五类问题,按频率排序。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
CREATE EXTENSION vector报错 | pgvector 插件未安装,或版本与 PostgreSQL 不匹配 | 检查share/extension是否有vector.control;Windows 环境重新下载匹配安装包 |
| LangChain4j 调用 Ollama 超时 | Ollama 服务未启动、端口错误、模型名错误 | 执行ollama list确认模型;访问http://localhost:11434验证服务 |
| 向量检索结果不相关 | 切片大小不合适、Embedding 模型与文本领域不匹配、相似度阈值太低 | 尝试 300-800 字符切片;换用中文效果更好的 Embedding 模型;调高相似度阈值 |
| 向量维度不一致报错 | 建表VECTOR(n)的 n 与模型输出维度不同 | 打印模型输出维度,修改表结构时重建列或重新建表 |
| Redis 连接被拒绝 | Redis 地址、端口、密码配置错误 | 检查application.yml;本地执行redis-cli -a password ping |
| WebSocket 连接成功但收不到通知 | Redis 频道名称不一致,或消息监听器未绑定 | 检查发布频道和订阅 Pattern 是否一致;查看 RedisNotifyHandler 日志 |
排查问题时,建议给RedisNotifyHandler和KnowledgeBaseService加详细日志。分布式场景下没有日志,问题几乎无法定位。
我在实际开发中遇到最多的是向量维度问题。Ollama 上不同模型输出的 embedding 维度差异很大,有的模型是 768 维,有的是 1024 维。建表的时候如果维度写错,查询时 PostgreSQL 会直接报错,而且是在数据已经入库之后才发现。建议在项目启动时增加一个自检任务:用一条样本数据跑一次向量化,把维度打印出来与表结构比对。
8. 最佳实践与工程建议
8.1 安全边界
文件权限是网盘的生命线。所有文件下载、删除、预览接口都必须校验文件归属。向量检索 SQL 中必须带owner_id条件,否则可能存在越权问答。
对于 AI 相关配置,Embedding 模型和对话模型的地址、API Key、模型名称不要明文写在代码仓库中,建议使用环境变量或配置中心管理。如果使用云厂商模型 API,API Key 泄露会造成真实经济损失。
8.2 异步与重试
文档解析和向量化是耗时操作,绝不能同步阻塞上传接口。建议方案是:文件上传接口只做文件存储和元数据入库,然后发送一条“待索引”消息到消息队列,由独立消费者完成 AI 入库。
如果当前技术栈里没有消息队列,可以先使用 Spring 的@Async+ 线程池。但要注意,线程池方案遇到进程重启会丢失任务,生产环境应尽快引入 MQ。
索引失败必须支持重试。重试时需要设置最大失败次数,超过次数后标记文件为“AI 索引失败”,允许用户重新触发索引。
8.3 缓存与数据库一致性
Redis 缓存文件元数据时,删除操作必须同时清缓存。建议在事务提交后清理,避免事务回滚但缓存已删的脏读问题。
分布式锁方面,推荐封装成注解或工具类,避免业务代码里到处写setIfAbsent和delete。更复杂的手段,比如锁续期,建议直接引入 Redisson。
8.4 向量索引与性能
当文档切片数量达到百万级时,必须使用 HNSW 或 IVFFlat 索引。没有索引的暴力搜索在数据量增大后性能会急剧下降。
HNSW 参数可以按数据规模调整:
CREATE INDEX idx_file_documents_embedding ON file_documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);查询时可以通过SET hnsw.ef_search = 100;调节召回质量与速度的平衡,这个参数越大,检索越精确,但耗时越长。
8.5 日志与可观测性
RAG 链路涉及文件解析、Embedding、向量检索、模型调用四个环节。建议为