系列导航
- 所属专栏:《Java 开发者从零实现 RAG 知识库》
- 学习位置:第 10 篇 / 共 12 篇
- 上一篇:《第9篇:知识入库基础,切分、向量化与 Point》
- 下一篇:《第11篇:RAG 知识工作台网页》
上一篇已经准备好 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 查看对应类。读代码时要抓住两条边界:
deleteByDocumentId()之前都属于准备阶段,不修改 Qdrant。- 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_chunksCollection 正常。 - 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
RetrievedChunk 和 RagSource 使用字符串保存 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
更新后出现重复知识
必须使用与旧文档相同的 documentId。source 只是展示来源,不负责定位需要替换的文档。
DELETE 后仍然看到旧答案
先确认 documentId 与入库时完全一致,再查看回答的 sources。模型可能使用自身知识生成相似文本,但被删除的文档不应再出现在真实来源中。
完成检查
- POST 返回文档 ID、来源和 Chunk 数量。
- 新知识可以通过
/api/rag/ask查询。 - 相同
documentId可以替换旧知识。 - DELETE 返回 HTTP 204。
- 参数错误返回 HTTP 400。
- 外部服务故障返回 HTTP 503。
- 测试文档已经删除,没有污染示例知识库。
本篇自测
- 为什么更新文档前不能只对新 Chunk 执行 upsert?
- 为什么先准备全部 Point,再删除旧知识?
- 删除旧 Point 成功、写入新 Point 失败时,当前流程是事务性的吗?
KnowledgeIngestionService在哪里被真正调用?- DELETE 为什么不需要调用
bge-m3?
参考答案:新版可能减少 Chunk,只 upsert 会残留旧 Point;避免切分或 Embedding 提前失败时破坏旧知识;两次远程请求不构成数据库事务;由 KnowledgeController 调用;删除根据 payload 中的 documentId 精确过滤。
本篇小结
KnowledgeStore隔离业务编排与 Qdrant SDK,Gateway 负责真实远程调用。- 更新流程先准备全部 Point,再删除旧 Point 并 upsert 新 Point,减少提前破坏旧知识的风险。
- 删除和写入仍是两个独立请求,不是数据库事务,失败后应使用相同文档重试。
- POST、DELETE 和已有查询接口组成知识新增、更新、查询、删除的完整生命周期。
下一篇
👉 本专栏下一篇:《第11篇:RAG 知识工作台网页》
完整代码都在 GitHub(欢迎 Star ⭐)
本专栏的全部示例代码都已开源,包含 5 个可独立运行的 Maven 模块、自动化测试和完整分篇教程。建议 Fork / Clone 下来,边读边跑:
🔗 https://github.com/bysbsh/ai-rag-learning-guide
- 代码与教程同步更新,对照每一篇动手实践效果最好。
- 如果这份教程帮到了你,点个 Star 就是对我最大的支持,也方便你之后找回最新版本。
- 遇到问题或发现错漏,欢迎在仓库提 Issue / PR。项目采用 MIT 协议,可自由学习与二次创作。
:知识管理 API,写入、更新与删除&spm=1001.2101.3001.5002&articleId=163826628&d=1&t=3&u=c999bb7f10eb47fdaa975b9c8d84df20)
341

被折叠的 条评论
为什么被折叠?



