Java RAG 实战(第 10 篇):知识管理 API,写入、更新与删除

系列导航

上一篇已经准备好 Point。本篇把它们写入 Qdrant,编排覆盖更新流程,再通过 Spring Boot HTTP 接口提供新增、更新和删除能力。

完成进度

  • 1. 理解 KnowledgeStore 与 Qdrant Gateway 的边界
  • 2. 使用 upsert 写入 Point
  • 3. 按 documentId 删除整篇旧文档
  • 4. 理解 embed → delete → upsert 顺序
  • 5. 使用 POST 接口新增或替换文档
  • 6. 使用 DELETE 接口删除文档
  • 7. 验证 200、204、400 和 503 响应

为什么要学

准备好 Point 还不等于完成入库。真实应用需要处理数据库写入、旧知识清理、失败边界和 HTTP 调用入口,才能让网页或其他服务稳定管理知识库。

第一阶段:Qdrant 写入与入库服务

第 1 步:设计 Qdrant 写入网关

程序需要两种存储操作:

upsert(points)                    批量写入新的知识 Point
deleteByDocumentId(documentId)    删除某篇文档的全部旧 Point

代码分成三层:

KnowledgeStore
      ↓ Qdrant 实现
QdrantKnowledgeStore
      ↓ 外部调用边界
QdrantWriteGateway
      ↓
QdrantClient(官方 SDK)

KnowledgeStore 是入库流程依赖的接口:

public interface KnowledgeStore {
    void upsert(List<PointStruct> points) throws Exception;

    void deleteByDocumentId(String documentId) throws Exception;
}

上层不需要了解 Qdrant SDK、超时或 Filter 的组装方式。Gateway 隔离真实网络调用,使单元测试不必启动 Qdrant。

第 2 步:批量 upsert

QdrantKnowledgeStore.upsert(points) 会把完整列表一次交给网关:

List<PointStruct>
        ↓
QdrantClient.upsertAsync(collectionName, points, timeout)

upsert 表示“存在就更新,不存在就新增”。Point ID 是稳定 UUID,因此同一个 documentId + chunkIndex 再次写入时会覆盖原 Point。空列表会被拒绝,避免发送没有内容的写请求。

第 3 步:按 documentId 删除

一篇文档通常被拆成多个 Point,所以删除不能只传一个 Point ID。程序会生成 payload Filter:

must:
  documentId == "kubernetes-guide"

Java SDK 组装方式:

Filter filter = Filter.newBuilder()
        .addMust(matchKeyword("documentId", documentId))
        .build();
  • documentId 读取 Point payload 中的同名字段。
  • matchKeyword 执行完整字符串匹配,不是向量搜索。
  • must 表示只有满足条件的 Point 才能删除。

空白文档 ID 会在调用网关前被拒绝,避免含义不清楚的删除条件。

第 4 步:连接真实 Qdrant

RagConfiguration 为网关提供真实 SDK 调用:

qdrantClient.upsertAsync(collectionName, points, requestTimeout).get();
qdrantClient.deleteAsync(collectionName, filter, requestTimeout).get();

当前学习项目使用 .get() 等待操作结束,让 HTTP 接口在返回成功前确认 Qdrant 已接受操作。两种调用共用配置:

rag:
  qdrant:
    collection: kubernetes_chunks
    request-timeout: 10s

第 5 步:编排完整入库流程

KnowledgeIngestionService 把已有组件组成一个完整用例:

MarkdownChunker.chunk(...)
          ↓
EmbeddingClient.embedAll(...)
          ↓
QdrantPointMapper.toPoint(...)
          ↓
KnowledgeStore.deleteByDocumentId(...)
          ↓
KnowledgeStore.upsert(...)

先完成不会修改 Qdrant 的准备阶段:

切分 → 批量 Embedding → 组装全部 Point

全部成功后才进入替换阶段:

删除旧 Point → 写入新 Point

因此 Ollama 失败、向量数量错误或 Point 组装失败时,旧知识不会被提前删除。测试要求事件顺序是:

embed → delete → upsert

需要注意,删除和写入是两个独立 Qdrant 请求,并非数据库事务。删除成功但写入失败时,文档会暂时没有数据,调用方应使用相同文档重试。

关键代码:KnowledgeIngestionService 怎样编排入库

对应源码:

05-spring-rag/src/main/java/com/example/ai/rag/ingestion/KnowledgeIngestionService.java

下面是 ingest() 的核心部分:

List<MarkdownChunker.Chunk> chunks = chunker.chunk(
        documentId,
        source,
        markdown
);
if (chunks.isEmpty()) {
    throw new IllegalArgumentException("Markdown 中没有可入库的二级章节");
}

List<double[]> embeddings = embeddingClient.embedAll(
        chunks.stream().map(MarkdownChunker.Chunk::content).toList()
);
if (embeddings.size() != chunks.size()) {
    throw new IllegalStateException("Embedding 数量与 Chunk 数量不一致");
}

List<PointStruct> points = new ArrayList<>(chunks.size());
for (int index = 0; index < chunks.size(); index++) {
    points.add(pointMapper.toPoint(chunks.get(index), embeddings.get(index)));
}

knowledgeStore.deleteByDocumentId(documentId);
knowledgeStore.upsert(List.copyOf(points));

为了突出业务顺序,上面片段省略了源码中的异常包装,完整实现请在 GitHub 查看对应类。读代码时要抓住两条边界:

  1. deleteByDocumentId() 之前都属于准备阶段,不修改 Qdrant。
  2. Chunk 和 Embedding 严格按相同 index 组装,所以数量不一致必须立即拒绝。

第 6 步:保证 Chunk 与 Embedding 配对

批量 Embedding 的返回顺序与输入相同:

chunks[0].content → embeddings[0]
chunks[1].content → embeddings[1]

服务在映射前检查:

embeddings.size() == chunks.size()

数量不同会抛出 IllegalStateException,并且不会删除或写入数据。这项保护不能只依赖当前 Ollama 客户端,因为以后可能替换 Embedding 实现。

第 7 步:拒绝空文档更新

当前规则只索引 ## 二级章节。下面的文档会得到 0 个 Chunk:

# 只有一级标题

没有二级章节。

如果继续更新,程序可能删除旧文档却没有新 Point 可写。因此服务会在调用 Embedding 和 Qdrant 前拒绝这种输入。

第 8 步:理解入库结果和 Spring Bean

成功后返回:

public record KnowledgeIngestionResult(
        String documentId,
        String source,
        int chunkCount
) {}

RagConfiguration 创建:

MarkdownChunker
PointIdGenerator
QdrantPointMapper
KnowledgeIngestionService

成为 Bean 只表示这些对象由 Spring 管理并可被注入;本文第二阶段会加入真正的 HTTP 调用入口。

测试验证什么

mvn -f 05-spring-rag/pom.xml test

相关测试验证:

  • upsert 使用正确 Collection 并保留全部 Point。
  • 删除使用 documentId 精确匹配。
  • 所有 Chunk 只执行一次批量 Embedding。
  • 全部 Point 准备完成后才删除和写入。
  • 空文档和向量数量错误不会修改原知识。
  • 返回正确的文档 ID、来源和 Chunk 数量。

完成检查

  • 理解 KnowledgeStore、实现类和 Gateway 的边界。
  • 理解 upsert 与按文档删除的区别。
  • 能解释为什么先准备全部 Point 再删除旧知识。
  • 能说明删除和写入为什么不是真正事务。
  • 05-spring-rag 测试全部通过。

第二阶段:知识管理 HTTP 接口

第 9 篇完成了切分、向量化和 Point 映射,本文第一阶段完成了 Qdrant 写入和 KnowledgeIngestionService。但 Service 仍只是一个等待调用的 Spring Bean,真正的应用还需要稳定的 HTTP 入口,让网页、脚本或其他服务都能管理知识。

本篇完成下面这条链路:

HTTP 请求
   ↓
KnowledgeController
   ↓
KnowledgeIngestionService
   ↓
MarkdownChunker → Ollama → Qdrant

前置条件

  • 已完成本篇前面的切分、向量化和 Qdrant 写入部分。
  • Ollama 已包含 bge-m3
  • Qdrant 容器和 kubernetes_chunks Collection 正常。
  • Java 17 和 Maven 3.9 可用。

第 1 步:理解 Controller 怎样调用 Bean

KnowledgeController 是知识管理的 HTTP 入口。Controller 构造方法中的参数由 Spring 自动注入:

public KnowledgeController(KnowledgeIngestionService ingestionService) {
    this.ingestionService = ingestionService;
}

对应源码:

05-spring-rag/src/main/java/com/example/ai/rag/api/KnowledgeController.java

Controller 对外暴露两个操作:

@PostMapping
public KnowledgeIngestionResult ingest(
        @Valid @RequestBody KnowledgeDocumentRequest request
) {
    return ingestionService.ingest(
            request.documentId(),
            request.source(),
            request.content()
    );
}

@DeleteMapping("/{documentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String documentId) {
    ingestionService.deleteDocument(documentId);
}

@PostMapping 没有额外路径,因此对应类上的 /api/knowledge/documents@DeleteMapping("/{documentId}") 则在后面增加文档 ID,删除成功时通过 @ResponseStatus 返回 HTTP 204。

请求到达后的调用关系:

POST /api/knowledge/documents
          ↓
KnowledgeController.ingest(...)
          ↓
KnowledgeIngestionService.ingest(...)
          ↓
MarkdownChunker → Ollama → Qdrant

这就是 knowledgeIngestionService Bean 真正被业务代码使用的位置。Controller 只转换 HTTP 数据,不自己切分 Markdown,也不直接操作 Qdrant。

第 2 步:启动应用

先确认依赖:

ollama list
docker ps --filter name=qdrant-study

再从仓库根目录启动:

mvn -f 05-spring-rag/pom.xml spring-boot:run

应用默认监听:

http://localhost:8080

第 3 步:提交或替换文档

在另一个终端执行:

curl -sS -X POST http://localhost:8080/api/knowledge/documents \
  -H 'Content-Type: application/json' \
  -d '{
    "documentId": "http-learning-demo",
    "source": "http-learning-demo.md",
    "content": "# Kubernetes\n\n## Service\n\nService 为 Pod 提供稳定访问地址。"
  }' | jq

成功响应:

{
  "documentId": "http-learning-demo",
  "source": "http-learning-demo.md",
  "chunkCount": 1
}

再次提交相同 documentId 会替换旧文档,而不是创建第二篇同名文档。示例使用独立 ID,避免覆盖第 9 篇写入的原始知识。

第 4 步:验证新知识可以查询

curl -sS -X POST http://localhost:8080/api/rag/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"什么为 Pod 提供稳定访问地址?"}' | jq

返回的 sources 中应包含:

source = http-learning-demo.md
title  = Service

这说明入库和查询使用的是同一个 Qdrant Collection,新知识不需要重启 Spring Boot 就能被检索。

第 5 步:删除测试文档

curl -i -X DELETE \
  http://localhost:8080/api/knowledge/documents/http-learning-demo

成功时返回:

HTTP/1.1 204 No Content

204 表示删除成功,但响应没有 JSON 正文。DELETE 只按 documentId 删除旧 Point,不调用 Embedding:

POST    切分、向量化,并替换整篇文档
DELETE  只按 documentId 删除旧 Point

第 6 步:理解请求校验和错误响应

KnowledgeDocumentRequest 的三个字段都使用 @NotBlank

documentId 不能为空
source     不能为空
content    不能为空

无效字段或没有 ## 二级章节时返回:

HTTP/1.1 400 Bad Request
{
  "code": "VALIDATION_ERROR",
  "message": "Markdown 中没有可入库的二级章节"
}

Ollama 或 Qdrant 调用失败时,服务层使用 ExternalServiceException 隐藏底层连接细节,统一返回:

HTTP/1.1 503 Service Unavailable
{
  "code": "EXTERNAL_SERVICE_UNAVAILABLE",
  "message": "外部服务暂时不可用"
}

第 7 步:理解真实生命周期验证

项目开发时使用独立文档完成了以下验证:

1. 入库“令牌默认有效期为 37 分钟”
2. Qdrant 文档数量增加 1
3. 问答接口返回“37 分钟”
4. 使用相同 documentId 更新为“52 分钟”
5. 文档数量不变,Point UUID 保持不变
6. 问答接口返回新答案“52 分钟”
7. DELETE 返回 204,Collection 恢复原数量

这证明:

  • 相同 documentId + chunkIndex 会生成稳定 UUID,更新不会累积重复 Point。
  • 查询读取的是 Qdrant 中更新后的内容,不会继续回答旧知识。
  • 删除整篇文档后,对应 Chunk 不再参与检索。

Qdrant Point ID 支持数字和 UUID 两种形式:

数字 ID:2
UUID:6d490220-6a6e-3d69-87d7-3f2f9e527376

RetrievedChunkRagSource 使用字符串保存 ID,并根据 Qdrant 实际设置的类型读取:

数字 2  → "2"
UUID    → "6d490220-6a6e-3d69-87d7-3f2f9e527376"

这样不会把 UUID 错误地读取成 protobuf 默认数字 0

常见问题

POST 返回 400

确认三个字段都不是空字符串,并且 Markdown 至少包含一个 ## 二级标题。

POST 返回 503

依次检查:

curl http://localhost:11434/api/tags
docker ps --filter name=qdrant-study
curl http://localhost:6333/collections/kubernetes_chunks

更新后出现重复知识

必须使用与旧文档相同的 documentIdsource 只是展示来源,不负责定位需要替换的文档。

DELETE 后仍然看到旧答案

先确认 documentId 与入库时完全一致,再查看回答的 sources。模型可能使用自身知识生成相似文本,但被删除的文档不应再出现在真实来源中。

完成检查

  • POST 返回文档 ID、来源和 Chunk 数量。
  • 新知识可以通过 /api/rag/ask 查询。
  • 相同 documentId 可以替换旧知识。
  • DELETE 返回 HTTP 204。
  • 参数错误返回 HTTP 400。
  • 外部服务故障返回 HTTP 503。
  • 测试文档已经删除,没有污染示例知识库。

本篇自测

  1. 为什么更新文档前不能只对新 Chunk 执行 upsert?
  2. 为什么先准备全部 Point,再删除旧知识?
  3. 删除旧 Point 成功、写入新 Point 失败时,当前流程是事务性的吗?
  4. KnowledgeIngestionService 在哪里被真正调用?
  5. DELETE 为什么不需要调用 bge-m3

参考答案:新版可能减少 Chunk,只 upsert 会残留旧 Point;避免切分或 Embedding 提前失败时破坏旧知识;两次远程请求不构成数据库事务;由 KnowledgeController 调用;删除根据 payload 中的 documentId 精确过滤。


本篇小结

  1. KnowledgeStore 隔离业务编排与 Qdrant SDK,Gateway 负责真实远程调用。
  2. 更新流程先准备全部 Point,再删除旧 Point 并 upsert 新 Point,减少提前破坏旧知识的风险。
  3. 删除和写入仍是两个独立请求,不是数据库事务,失败后应使用相同文档重试。
  4. POST、DELETE 和已有查询接口组成知识新增、更新、查询、删除的完整生命周期。

下一篇

👉 本专栏下一篇:《第11篇:RAG 知识工作台网页》

完整代码都在 GitHub(欢迎 Star ⭐)

本专栏的全部示例代码都已开源,包含 5 个可独立运行的 Maven 模块、自动化测试和完整分篇教程。建议 Fork / Clone 下来,边读边跑:

🔗 https://github.com/bysbsh/ai-rag-learning-guide

  • 代码与教程同步更新,对照每一篇动手实践效果最好。
  • 如果这份教程帮到了你,点个 Star 就是对我最大的支持,也方便你之后找回最新版本。
  • 遇到问题或发现错漏,欢迎在仓库提 Issue / PR。项目采用 MIT 协议,可自由学习与二次创作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值