从上传到可问答:Interview Agent 的知识库 RAG 链路
Meta description: 拆解 Interview Agent 知识库功能如何用 RustFS 保存原文件、Redis Stream 异步向量化、pgvector 检索,并通过 RAG 生成回答。
用户上传一份 PDF 或 Markdown,系统几秒后就能围绕它问答。这个体验看起来像一个普通的文件上传功能,但真正麻烦的地方在后面:文件要能下载、文本要能解析、向量要能重建、检索要能过滤,失败了还要能让前端看见状态。
Interview Agent 的知识库功能没有把这些事情揉成一个同步接口,而是拆成了一个比较清晰的工程链路:原始文件进 RustFS/S3,业务状态进 PostgreSQL,向量进 pgvector,耗时任务走 Redis Stream。
为什么这个功能值得拆出来讲
RAG 的 Demo 很容易写:读一个文件、切 chunk、调 embedding、存向量、检索后塞给大模型。
但项目一旦进入真实应用,问题会马上变具体:
- 上传接口不能一直阻塞等向量化完成。
- 原始文件不能只存在临时目录,否则重试和下载都会失效。
- 向量化失败不能只打日志,前端需要知道它失败了。
- 多个知识库一起检索时,向量结果必须能按知识库过滤。
- 用户问得太短或太泛时,检索参数要能动态调整。
这也是本项目知识库链路的核心价值:它不是只把 RAG 跑通,而是把 RAG 做成了一个可恢复、可观测、可维护的业务功能。
总体链路
从一次知识库上传到一次 RAG 问答,大致可以拆成两条链路。
第一条是上传和向量化:
上传文件
校验类型和大小
计算 hash 去重
解析文本内容
原始文件保存到 RustFS/S3
元数据保存到 PostgreSQL
vectorStatus=PENDING
投递 Redis Stream 向量化任务
Consumer 切分文本并写入 pgvector
更新状态 COMPLETED / FAILED
第二条是检索和回答:
用户提问
query normalize / rewrite
按问题长度选择 topK 和 minScore
pgvector 相似度检索
命中有效性校验
拼接上下文
调用大模型生成回答
同步返回或 SSE 流式返回
相关代码入口主要在:
app/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseUploadService.javaapp/src/main/java/interview/guide/modules/knowledgebase/listener/VectorizeStreamConsumer.javaapp/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseVectorService.javaapp/src/main/java/interview/guide/modules/knowledgebase/service/KnowledgeBaseQueryService.javaapp/src/main/java/interview/guide/infrastructure/file/FileStorageService.java
上传阶段:先保存事实,再处理重活
KnowledgeBaseUploadService 做的事情很有代表性。它没有在 Controller 里直接完成所有步骤,而是把上传拆成了几个明确动作:
- 校验文件大小和类型。
- 计算文件 hash,避免重复上传。
- 用解析服务提取文本内容。
- 把原始文件上传到 RustFS/S3。
- 把文件元数据保存到数据库。
- 发送向量化任务到 Redis Stream。
- 立即返回
vectorStatus=PENDING。
这套顺序背后的关键判断是:上传成功不等于向量化完成。
如果把 embedding 和向量写入都放在上传请求里,接口会被几个不稳定因素拖住:文件大小、解析耗时、embedding API 延迟、pgvector 写入耗时、外部模型服务失败。用户体验上就是上传按钮一直转圈,后端也更容易出现请求超时。
所以项目选择先把"已经接收了这个知识库"的事实落库,再把向量化作为异步任务处理。前端拿到的是一个明确状态,而不是等待一个不可控的长请求。
为什么原文件要进 RustFS,而不是进数据库
知识库文件在这个功能里有两个身份:
- 它是业务对象,需要有名称、分类、大小、上传时间、向量化状态。
- 它也是二进制文件,需要支持保存、下载、删除、重新解析。
数据库适合前者,对象存储适合后者。
本项目的 KnowledgeBaseEntity 保存的是 fileHash、originalFilename、fileSize、contentType、storageKey、storageUrl、vectorStatus 等元数据;真正的文件内容由 FileStorageService 通过 S3 SDK 放进 RustFS/S3。
这个分工有几个实际收益:
- 数据库不会因为 PDF、DOCX 这类大对象快速膨胀。
- 文件下载不需要穿透复杂的业务表结构。
- 后续可以把 RustFS 换成 MinIO、OSS、S3,业务表结构不用大改。
- 向量化失败后,可以从对象存储重新下载原文件并重试。
换句话说,数据库记录"这个文件是谁、在哪里、处理到哪了",RustFS 负责"文件内容本身"。
向量化阶段:Redis Stream 承接不稳定耗时任务
向量化不是一个适合放在同步请求里的动作。
在本项目里,上传服务会把 kbId 和解析出的 content 投递到 Redis Stream。VectorizeStreamConsumer 消费任务后,按生命周期更新状态:
- 开始处理:
PROCESSING - 成功完成:
COMPLETED - 处理失败:
FAILED,并记录错误信息
真正的切分和写入由 KnowledgeBaseVectorService 完成。它会先删除该知识库旧的向量数据,再使用 TokenTextSplitter 切分文本,并给每个 chunk 加上 kb_id metadata,最后分批写入 VectorStore。
这里有一个细节很重要:项目把每批向量化大小限制为 10。原因是 embedding 服务通常会有批量请求限制,分批处理可以避免一次性提交过多 chunk 导致失败。
简化后的流程可以理解成这样:
java
deleteByKnowledgeBaseId(kbId);
List<Document> chunks = textSplitter.apply(List.of(new Document(content)));
chunks.forEach(chunk -> chunk.getMetadata().put("kb_id", kbId.toString()));
for (List<Document> batch : batches(chunks, 10)) {
vectorStore.add(batch);
}
这段逻辑的关键不是"切文本"本身,而是它保证了两个工程语义:
- 重新向量化时不会混入旧 chunk。
- 检索时可以按
kb_id精确过滤知识库范围。
检索阶段:不是简单 topK,而是动态召回
很多 RAG 问答质量差,并不是因为大模型回答能力差,而是检索阶段把错误上下文喂给了模型。
本项目的 KnowledgeBaseQueryService 在检索前做了几层处理:
- 对问题做基础 normalize。
- 提取精确 token,避免 rewrite 把技术关键词改坏。
- 根据问题长度选择不同的
topK和minScore。 - 先尝试 rewrite query,再保留原 query 作为候选。
- 对检索命中做有效性校验。
- 没有有效命中时,不调用模型,直接返回无结果提示。
动态参数这一点很实用。短问题往往信息密度低,比如用户只问"索引"或"事务",这时需要更大的 topK 和更低一点的阈值来扩大召回;长问题通常上下文更明确,可以减少 topK,避免把太多弱相关片段塞进 prompt。
这比固定 topK=5 更贴近真实使用场景。
生成阶段:只在有有效上下文时调用模型
检索阶段如果没有有效命中,系统不会硬让大模型编一个答案,而是返回固定的无结果提示。
这个设计很小,但能明显降低幻觉。RAG 系统最怕的是"没检索到也回答得很自信"。在面试辅助类系统里,这种错误尤其危险,因为用户可能会把它当成复习材料。
当检索有效时,系统会把 chunk 文本拼成 context,再通过 prompt 模板构造最终请求。同步问答走普通 call(),流式问答走 stream().content() 并通过 SSE 返回。
因此,同一套检索逻辑可以服务两种体验:
- 普通知识库问答:一次性返回答案。
- RAG Chat:边生成边返回,前端体验更像聊天。
这个设计的取舍
这套链路不是最简单的实现。
如果只是课堂 Demo,一个接口里完成文件解析、embedding、入库、回答,代码会少很多。引入 RustFS、Redis Stream、pgvector 之后,组件确实变多了,部署和排错成本也更高。
但项目换到的是更清晰的职责边界:
- RustFS/S3 管原始文件。
- PostgreSQL 管业务元数据和状态。
- Redis Stream 管异步任务。
- pgvector 管向量检索。
- 大模型只在检索有效后负责生成回答。
当文件变大、用户变多、任务失败需要重试、知识库需要重新向量化时,这种拆分会比单接口硬扛更稳。
可以继续优化的地方
当前实现已经覆盖了主链路,但还有一些可以继续演进的方向:
- 向量化任务只传
content,大文件场景下 Redis Stream 消息会变大。后续可以只传kbId,Consumer 再从 RustFS 下载并解析。 - 可以记录每个 chunk 的来源页码、标题或段落位置,让回答支持引用来源。
- 可以为向量化增加进度字段,例如
processedChunks / totalChunks。 - 可以把失败重试策略暴露到后台管理界面,而不只是提供手动重新向量化接口。
- 可以对不同文档类型使用不同 splitter,提高代码文档、Markdown、PDF 的切分质量。
这些优化不改变主架构,只是在现有链路上补充可观测性和召回质量。
总结
Interview Agent 的知识库功能可以概括为三句话:
- 原始文件不要塞进业务数据库,放进 S3 兼容对象存储更合适。
- 向量化是耗时且不稳定的任务,应该通过 Redis Stream 异步处理并落状态。
- RAG 质量不只取决于大模型,检索参数、query rewrite、命中校验同样关键。
如果你正在做 Java RAG 项目,可以先不要急着优化 prompt。先问自己三个问题:文件能不能重试处理?向量化失败前端能不能看见?检索不到时系统会不会胡答?
这三个问题回答清楚,RAG 才真正从 Demo 走向工程。