📌系列说明:一个 Java 后端视角的 Spring AI 渐进式实战教程,载体为开源项目「劳小司 · 智能法律助手」。
- 序章:技术栈全景与 AI 学习指南
- 阶段一 · 流式对话内核:篇1 SSE 流式·停止·思考可见化 / 篇2 会话记忆压缩与滚动体验
- 阶段二 · 工具调用:篇1 Function Calling 与法律计算器 / 篇2 联网搜索与工具预算
- 阶段三 · RAG 知识库:篇1 起步与底账化(本文)/ 篇2 Agentic RAG 与引用可信度 / 篇3 检索质量与体验
- 阶段四 · 多模型路由:篇1 五路级联路由
- 阶段五 · 安全与质量门:篇1 安全层与强制检索 / 篇2 质量门与评估门禁 / 篇3 指代消解与阻塞隔离
- 阶段六 · 产品化与用户体系:篇1 认证·配额·门禁 / 篇2 前端·移动端·身份 / 篇3 劳动法专精与多模态
- 阶段七 · 存储演进与部署:篇1 存储迁移 / 篇2 部署契约
📦本篇涉及:
rag/KnowledgeImportService.java、rag/LawArticleEntity.java、application.yml(pgvector 段)。
一、RAG 要解决的两个硬伤
问大模型"劳动合同法第 47 条经济补偿怎么算",它大概率能答个大概,但有两个硬伤:幻觉(条文编号张口就来,无法验证)和不可更新(知识停在训练截止日,你自有的条文它一条都装不进)。
RAG(检索增强生成)的思路:
用户提问 → 先去知识库检索相关条文 → 把条文拼进 Prompt → 模型"有法可依"地回答模型的角色从"背书答题"变成"开卷考试"。本篇先把最基础的一环打通——向量化 + 检索,再解决一个真正的工程问题:法条会增删改,知识库怎么低成本同步。
二、三个技术零件
① Embedding:把文本编码成高维向量,语义相近则向量距离近。本项目用百炼qwen3.7-text-embedding-flash(1024 维)。用户哪怕用大白话"被公司辞退能拿多少",也能命中规范表述"解除劳动合同经济补偿"。
② 向量库:Spring AI 把各家向量库统一成VectorStore接口——add(documents)写入、similaritySearch(request)检索。换向量库只换 starter,业务代码不动。本项目用pgvector(PostgreSQL 的向量扩展 + HNSW 索引),好处是底账、向量、会话记忆同库,运维最简(序章第五节展开过存储可插拔)。
③ Naive RAG:最朴素的用法——每轮对话前置检索 Top-K,拼进 System Prompt。它有三个问题(闲聊也白检索、检索词就是用户原话、只能检一轮),正是篇2 要改进的地方。本篇先把"检索得到、同步得起"做扎实。
三、底账化:唯一事实源 + 可重建索引
这是本篇的核心工程决策。知识库不是一坨向量,而是两层:
PG law_article(底账,唯一事实源) │ 导入管道:全量扫描 → MD5 对比 → 向量化 ▼ PG vector_store(pgvector 检索索引,可随时用底账重建)铁律:law_article是唯一事实源,vector_store只是检索索引——它随时可以删掉重建,坏了不心疼。这条分工让"换向量库"“重建索引”"改 embedding 模型"都变成低风险操作。
早期是"启动时一次性把所有条文灌进向量库",法条一多,每次改一个字就要全量重嵌,embedding 费用和时间都扛不住。于是引入增量同步。
四、MD5 指纹增量同步:新增 / 变更 / 删除三态
每条条文算一个 MD5 指纹,和指纹底账表rag_ledger(articleId → "md5;docId列表")对比,得出三态差异,只对差异部分做向量化:
Stringhash=md5(article.toEmbeddingText()+"|"+embeddingModel);// 注意掺了模型名Stringold=oldHashes.get(idStr);if(old==null||!old.startsWith(hash+";")){if(old!=null){vectorStore.delete(parseDocIds(idStr,old));// 变更:先回收旧向量}pending.add(newArticleSync(idStr,ledgerValue,docs));// 新增/变更:待重嵌}// 底账有、PG 已删/逻辑删 → 回收向量Set<String>toDelete=newHashSet<>(oldHashes.keySet());toDelete.removeAll(newHashes.keySet());改一个字都会触发该条重嵌;没变的一条都不动。468 条法条里只改 3 条,就只重嵌 3 条。
一个关键设计——指纹掺入 embedding 模型名:md5(文本 + "|" + embeddingModel)。换向量模型后,所有指纹失配,自动触发全量重建。为什么?因为新旧模型的向量不在同一语义空间,混在一起检索质量会静默劣化(不报错,但结果变差,最难查)。把模型名掺进指纹,换模型 = 必然全量重建,杜绝混用。
五、确定性文档 ID:增量同步的前提
要"精准重导入 / 精准回收",向量文档的 ID 必须是确定的——同一条文永远同一个 ID。本项目用语义键law:article:{id}(长条文分块则law:article:{id}#c{i})。
但这里有个坑:PgVectorStore 的 id 列是 uuid 类型(写入时UUID.fromString),不接受law:article:1001这种字符串键。解决是用 **UUID v3(MD5 派生)**把语义键确定性映射成 UUID:
privatestaticStringdeterministicUuid(Stringkey){returnUUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString();}同键永远同 UUID,"确定性 ID"的精准重导入/回收决策得以保留。
六、长条文分块(Small-to-Big 的前半)
一条法条可能很长,整条向量化会稀释语义。导入时按chunk-max-length(480 字)把长条文拆成多个子块文档(#c0、#c1……),短条文整条一个文档。检索粒度用小块保精度——命中哪个子块算哪个;至于"命中子块后还原整条全文注入"(注入粒度保完整),是篇3 检索管线的第五步,这里先把分块存好。
七、断点续传与进度
大批量重嵌可能中途失败。设计成每批(BATCH=10)embedding 写入后立即落 ledger checkpoint(条文粒度):中断后再点同步,已 checkpoint 的条文 MD5 命中自动跳过——断点即 ledger。进度写 Redisadmin:sync:progress供管理端轮询,多实例共享。同步改由管理端按钮手动触发(import-on-startup: false),不再启动即跑,避免每次重启都扫库。
八、踩坑备忘
① DashScope embedding 批量上限 10 条/请求。一次塞超过 10 条直接报错。所以BATCH = 10,攒够就 flush。
② 改维度/索引类型不会触发重建。initialize-schema: true每次启动执行的是CREATE ... IF NOT EXISTS——已存在就空转。这意味着改dimensions/index-type/distance-type配置不生效(旧表不动)。正确四步:DROP TABLE vector_store→DELETE FROM rag_ledger(不清指纹会判"无变更"不重嵌)→ 重启按新配置建表 → 管理端点一次全量重嵌。而仅换 embedding 模型名不需要这套流程——因为模型名已掺进指纹,点同步自动全量重建。
③ 勾选部分法律同步时,删除回收必须限定在同一作用域。否则未勾选法条的指纹不在本次newHashes里,会被误判成"底账已删"而整体回收掉向量——这是勾选同步最危险的坑,代码里用toDelete.retainAll(作用域内 id)兜住。
④ 向量库不可用不能让启动崩。导入失败仅告警不阻断启动,检索侧会降级为空(对话退化为纯 LLM)。
九、小结
| 概念 | 一句话 |
|---|---|
| 底账化 | law_article 唯一事实源,vector_store 可重建索引 |
| MD5 增量 | 新增/变更/删除三态,只重嵌差异,掺模型名防混用 |
| 确定性 ID | 语义键经 UUID v3 映射,精准重导入/回收的前提 |
| 分块 | 长条文拆子块,小块保检索精度 |
| 断点续传 | 每批落 ledger checkpoint,中断从断点继续 |
十、下篇预告
检索能跑、同步省钱了,但 Naive RAG"每轮无条件前置检索"仍然又费又不准,而且引用可信度是假的——前端引用卡展示的是"模型说了什么"而不是"检索命中了什么"。下一篇把检索交给模型自主决策(Agentic RAG),并做引用可信度专项。
🌐源码与体验:Gitee(国内快)https://gitee.com/spaserby/laoxiaosi.git | GitHub https://github.com/spaserby/laoxiaosi.git
🖥 在线演示:https://laoxiaosi.noctisblue.com
本系列全套代码皆开源,觉得这篇有帮助,欢迎顺手点颗 ⭐