SpringAI+RAG-检索文档变知识:从上传到精准检索的完整链路

把文档变成能搜的知识:md/txt 读取、切片、打标签与向量检索

记录人:老码

一、开场:文档传上去了,答案还是编的

场景你肯定见过:用户上传一份公司文档,转头问「年假怎么算」。模型答得头头是道,数字编得比真的还像。

文档内容直接贴进提示词能对付一两次。文档一多、用户一多,提示词塞不下,钱也烧不起。

得换个做法:文档先存到一个能按语义检索的地方,用户提问时只捞相关的那几段,交给模型组织语言。这就是 RAG。

第一期的范围怎么划、哪些事明确先不做,写在 \[13-文档读取第一期方案与取舍];这篇只讲怎么做。

二、人话解释:RAG 的活拆成写入和读取两段

写入段叫 ETL,跟数据仓库那套一个意思,只是搬的是文本:

环节 干什么 用的组件
Extract 把文件读成 Document TextReaderMarkdownDocumentReader,再往后是 PDF、Tika
Transform 把大 Document 切成小 Document TokenTextSplitter
Load 把小 Document 向量化后存起来 PgVectorStoreSimpleVectorStore

读取段更简单:把问题也向量化,去向量库找最像的几段,拼成上下文,再让模型基于上下文回答。

两段里最容易出问题的不是模型,是中间那层切分和标签。模型再聪明,你给它捞错了片段,它也只能照着错的编。

三、生活类比:图书馆不是把书扔地上就能查

文档是书,切片是便签,向量是索引卡,元数据是书架分区。

书搬进来不能往地上一堆。得先编目、撕成便于取用的便签、贴上属于哪个分区的标签,上架之后才谈得上"查"。读者来问,管理员照着索引卡找到那几张便签,递给讲解员组织成答案。

讲解员只负责讲,不负责找。找错了,讲得越流畅越糟糕。

四、落地:导入链路的六步与异步改造

接口一共五个,用户标识暂时用请求头 X-User-Id 传,接了登录体系之后换成从 Token 解析:

方法 路径 用途
POST /api/knowledge/documents 上传文档,form 字段 file + visibility
GET /api/knowledge/documents 列出自己可见的文档
GET /api/knowledge/documents/{docUuid} 查单个文档的导入状态与失败原因
DELETE /api/knowledge/documents/{docUuid} 删除文档及其向量
POST /api/knowledge/ask 检索问答

配置长这样,密钥和连接信息全部走环境变量:

yaml 复制代码
love:
  rag:
    embedding:
      api-key: ${DASHSCOPE_API_KEY}
      base-url: https://dashscope.aliyuncs.com
      embeddings-path: /compatible-mode/v1/embeddings
      model: text-embedding-v3
    pgvector:
      host: ${PGVECTOR_HOST}
      port: ${PGVECTOR_PORT:5432}
      database: ${PGVECTOR_DATABASE}
      username: ${PGVECTOR_USERNAME}
      password: ${PGVECTOR_PASSWORD}
      table-name: vector_store
      dimensions: 1024
      index-type: HNSW
      distance-type: COSINE_DISTANCE
    chunk-size: 800
    min-chunk-size-chars: 350
    top-k: 4

4.1 校验加去重

第一期只放 md 和 txt 进来,格式白名单外的一律拒绝。同一份文件重复上传是常见操作,按内容的 sha256 去重,省一次嵌入的钱:

java 复制代码
String sha256 = DigestUtil.sha256Hex(bytes);
KnowledgeDocument existing = documentMapper.selectOneByQuery(
        QueryWrapper.create().where("user_id = ?", userId).and("sha256 = ?", sha256));
if (existing != null && STATUS_DONE.equals(existing.getStatus())) {
    return toInfo(existing);
}

4.2 解析

两种格式走两个 Reader,Markdown 单独用 MarkdownDocumentReader,它能按标题结构拆 Document:

java 复制代码
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
    return new MarkdownDocumentReader(resource, MarkdownDocumentReaderConfig.defaultConfig()).get();
}
return new TextReader(resource).get();

4.3 切片

切片大小是成本和质量的总开关:

java 复制代码
TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(800)              // 每片最多 800 token
        .withMinChunkSizeChars(350)      // 太短的片不单独成片
        .build();

片太大,一次捞回来半页噪声,上下文被无关内容占满;片太小,一句完整的话被劈两半,检索回来缺主语,模型只能猜。

4.4 打标签

这一步决定了后面能不能做权限和清理,元数据必须在这时候就带全:

java 复制代码
metadata.put("doc_uuid", row.getDocUuid());
metadata.put("user_id", userId);
metadata.put("file_name", row.getFileName());
metadata.put("visibility", visibility);
metadata.put("chunk_index", index);

4.5 用确定性 ID

切片 ID 不是随机生成的,而是从文档号和序号推出来的:

java 复制代码
private String chunkId(String docUuid, int index) {
    return UUID.nameUUIDFromBytes((docUuid + ":" + index).getBytes(StandardCharsets.UTF_8)).toString();
}

理由很实在:删除的时候要能算出"这篇文档一共占了哪些 ID"。nameUUIDFromBytes 是确定性哈希,同样的输入永远得到同一个 UUID,写入和删除两边算出来是一致的。顺带也满足了 PgVector 对 ID 格式必须是 UUID 的硬要求。

4.6 落库

java 复制代码
List<Document> chunks = splitAndTag(documents, row, userId, effectiveVisibility);
vectorStore.add(chunks);

MySQL 那边同时维护一张 knowledge_document 表,记录文档号、文件名、大小、sha256、可见性、状态和切片数。statusPROCESSING 走到 DONE,失败记 FAILED 并把错误信息截断存下来。

向量和元数据分开存是有意的:向量库负责"像不像",关系库负责"谁传的、能不能删、删干净没有"。

4.7 把上面六步搬到后台跑

这六步一开始是同步做的,问题很直白:文档一大,上传接口就卡在那儿等切片和嵌入跑完,几十秒起步,前端只能干等。

改成异步之后,上传接口只做两件事------把文件存进自己的目录,往 MySQL 插一条 PROCESSING 记录,然后立刻返回。剩下的活交给后台线程,前端拿 docUuid 去轮询状态接口。

java 复制代码
// 同步部分:落盘 + 登记,然后提交任务就返回
documentMapper.insertSelective(row);
submitImport(docUuid);
return toInfo(row);          // 此时状态是 PROCESSING

有两个坑必须提前避开。

@Async 的方法得放在另一个 Bean 里。它靠 Spring 代理生效,同一个类里 this.process() 自己调自己,压根不经过代理,任务会当场退化成同步执行------代码看着没错,行为全错。

文件必须在提交任务之前 落盘。MultipartFile 背后的临时文件在请求结束就被清理,后台线程再去读已经没了。

后台那一侧还有三道保险:

java 复制代码
// 收尾时带上状态条件:这行被人删过或改过,就不能再写成 DONE
int updated = documentMapper.updateByQuery(done, QueryWrapper.create()
        .where("doc_uuid = ?", docUuid)
        .and("status = ?", "PROCESSING"));
if (updated == 0) {
    safeDeleteVectors(docUuid, writtenChunks);   // 刚写进去的向量成了孤儿,清掉
}

失败时按本次切片数把向量删干净,不留"只进了一半"的文档参与检索;删除接口碰到 PROCESSING 直接拒绝,防止"刚删完、后台又把向量写回来";线程池用有界队列,队列满了把记录标成 FAILED 并写明原因,不许出现永远停在 PROCESSING 的僵尸行。

五、检索:权限过滤要在检索时就生效

多用户隔离最容易做错的地方在这。检索时的过滤条件是这样拼的:

java 复制代码
FilterExpressionBuilder builder = new FilterExpressionBuilder();
Filter.Expression filter = builder
        .or(builder.eq("user_id", userId), builder.eq("visibility", VISIBILITY_PUBLIC))
        .build();

List<Document> hits = vectorStore.similaritySearch(SearchRequest.builder()
        .query(request.question())
        .topK(topK)                  // 默认 4
        .filterExpression(filter)
        .build());

三个点值得说明。

过滤条件要交给向量库去执行,别捞回来在 Java 里筛。原因是相似度检索的截断发生在过滤之后:数据库层面就是 WHERE ... ORDER BY ... LIMIT。你要是先捞 4 条再在代码里剔掉别人 3 条,你实际只用了 1 条,还白花了检索开销。

visibility 是检索条件,不是前端的显示开关。前端藏起来的按钮挡不住接口调用。

这里问的是另一个 ChatClient,没挂对话记忆 Advisor,免得知识库问答把恋爱咨询的会话记忆带偏。

答案拼装时给每段资料编号,让模型标注引用:

java 复制代码
String answer = ragChatClient.prompt()
        .system("你是知识库助手。只依据用户提供的资料回答问题,"
                + "资料里没有的信息要明确说不知道,不要编造;回答末尾用 [编号] 标注引用来源。")
        .user("资料:\n" + context + "\n问题:" + request.question())
        .call()
        .content();

返回体里除了答案,还带一份来源列表,前端能点开看到是哪个文件的第几片。用户能核对,你才能接住"模型瞎说"的投诉。

六、实测结果

拿一篇 40 节的 md 文档跑了一遍完整链路,数据都在真实环境里:上传接口立刻返回 PROCESSING,后台把这篇文档切成 80 片,PostgreSQL 的 vector_store 表按 doc_uuid 统计正好 80 行,和 knowledge_document.chunk_count 对得上;调用删除接口后,该文档的向量行数归零。

检索侧用一个标记串做了验证:写入一条带独特标记的文档,用这个标记当查询词,topK=1 加标记过滤,能准确命中刚写进去的那条,返回的 ID 和写入时一致。

异步这条路径单独测了两件事:上传返回时状态必须是 PROCESSING------这篇文档要发多次嵌入请求,不可能在返回前跑完;导入进行中调删除接口必须被拒。两条都过了。

七、坑点提醒

  • 别把 PG 的数据源注册成 Bean。 项目主库是 MySQL,Spring Boot 按类型找 DataSource。你再放一个 PG 的 DataSource Bean 进去,主数据源会被顶掉,MyBatis-Flex 连的库直接换人。所以在建 VectorStore 的方法内部 new 一个 HikariDataSource,不要交出去:

    java 复制代码
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel, LoveRagProperties properties) {
        LoveRagProperties.PgVector pg = properties.getPgvector();
        HikariConfig hikariConfig = new HikariConfig();
        hikariConfig.setJdbcUrl("jdbc:postgresql://%s:%d/%s"
                .formatted(pg.getHost(), pg.getPort(), pg.getDatabase()));
        HikariDataSource dataSource = new HikariDataSource(hikariConfig);
        // 交给 PgVectorStore,方法执行完不要暴露给容器
        return PgVectorStore.builder(new JdbcTemplate(dataSource), embeddingModel)
                /*
                 * 省略维度、索引类型、距离算法等配置
                 */
                .build();
    }
  • PgVector 的 ID 必须是 UUID。doc-1 这种进去,插入直接报错。

  • DeepSeek 没有嵌入接口,向量化得换一家。 而且 OpenAI 的自动配置会顺手配一个指向 DeepSeek 地址的 embedding 模型,得显式排掉:@SpringBootApplication(exclude = OpenAiEmbeddingAutoConfiguration.class)

  • 余额不足报的是 402。 嵌入服务余额用完,抛的是 NonTransientAiException: HTTP 402 - Insufficient Balance。看到 NonTransient 就知道重试没用,去充值,别在代码里找 bug。

  • 删向量别指望按条件删。 早期用 SimpleVectorStore 试过 Filter 删除,它不支持,所以才改成确定性 ID、按 ID 删。换到 PgVector 之后这条路依然最省事,也不需要额外权限配置。

  • PG 的库要选对。 连默认的 postgres 库没有建 schema 权限,会卡在初始化表结构那一步。给它一个独立库。

  • 切片是成本开关。 800 token 一片、每轮捞 4 片,等于每次问答固定消耗三千多 token 的上下文。这个值调大之前先算账。

  • 异步了也别把上传当导入成功。 上传接口返回的 PROCESSING 只代表任务排上了,真正的结果要看状态接口。前端该轮询就轮询,别拿 200 当"导入完成"。

  • 队列是有界的。 线程池队列排满时任务会被拒,记录直接标 FAILED 并写明原因。并发上传量大的话,queue-capacity 和线程数都得按机器调。

八、老码的总结

把文档变成能搜的知识,真正要做的是三件事:读进来、切干净、贴好标签。模型只负责最后那一段组织语言。

权限过滤记得交给向量库,别自己捞回来筛;切片 ID 记得用确定性生成,删的时候才找得回。

先看日志,别慌,问题不大。

相关推荐
QQ_21696290961 小时前
基于SpringBoot+Vue的小生活平台的设计与实现
java·数据库·vue.js·spring boot·spring·微信小程序·生活
宁渡AI大模型3 小时前
河南宁渡科技有限公司|宁渡课堂 AI 全栈面试分享,RAG 项目面试深挖问题解析
人工智能·机器学习·rag
Wang's Blog9 小时前
Java框架快速入门: Spring Security+OAuth2之数据库和实体类的RBAC改造
java·数据库·spring
猫吻鱼12 小时前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai
Wang's Blog14 小时前
Java框架快速入门: Spring Security+OAuth2之JWT核心概念与实战
java·spring·log4j
Wang's Blog15 小时前
Java框架快速入门: Spring Security+OAuth2之核心角色与授权流程
java·spring·github
Wang's Blog16 小时前
Java框架快速入门: Spring Security+OAuth2之RBAC与角色分层实践
java·网络·spring
XLYcmy16 小时前
DeepMMSearch-R1: Empowering Multimodal LLMs in Multimodal Web Search论文分享
llm·sft·强化学习·多模态·苹果·rag·检索
java_logo16 小时前
Docker 部署 Milvus:轻松搭建高性能向量数据库平台
数据库·docker·私有化部署·milvus·向量数据库·rag·轩辕镜像