用Spring AI+Qdrant实现可恢复的RAG增量索引服务
文章摘要
本文实现一个面向企业知识库的增量索引服务:通过文档Checksum和Chunk Hash识别变化,只对新增或修改片段生成Embedding;使用稳定Point ID写入Qdrant;通过版本状态实现新旧索引原子切换;失败任务可重试,删除操作可幂等执行。示例使用Spring Boot、Spring AI和Qdrant,重点展示工程结构、数据模型、任务状态和核心代码。
一、目标架构
上传或数据变更
→ 文档版本记录
→ 解析与分块
→ Chunk差异计算
→ Embedding
→ Qdrant写入READY
→ 完整性校验
→ 版本切换
→ 清理旧版本与缓存
目标:
- 未变化Chunk不重复Embedding;
- 新版本未完成前不影响生产;
- 任务失败可以恢复;
- 删除操作幂等;
- 所有Point可以追踪来源;
- 支持多租户和版本过滤。
二、项目结构
rag-indexing-service
├── domain
│ ├── KnowledgeDocument.java
│ ├── DocumentVersion.java
│ ├── KnowledgeChunk.java
│ └── IndexTask.java
├── application
│ ├── IncrementalIndexService.java
│ ├── DocumentParser.java
│ └── VersionSwitchService.java
├── infrastructure
│ ├── QdrantChunkRepository.java
│ ├── SpringAiEmbeddingGateway.java
│ └── JdbcDocumentRepository.java
└── web
└── KnowledgeIndexController.java
三、核心数据模型
public enum VersionStatus {
UPLOADED,
PARSING,
INDEXING,
READY,
EFFECTIVE,
EXPIRED,
FAILED
}
public record DocumentVersion(
String versionId,
String logicalDocumentId,
long version,
String fileChecksum,
VersionStatus status,
Instant createdAt
) {
}
Chunk:
public record KnowledgeChunk(
String chunkId,
String logicalDocumentId,
String versionId,
String sectionPath,
int chunkIndex,
String content,
String contentHash,
Map metadata
) {
}
四、稳定Point ID
不要每次生成随机UUID。
public String buildPointId(
KnowledgeChunk chunk
) {
String raw = String.join(
":",
chunk.logicalDocumentId(),
chunk.versionId(),
chunk.sectionPath(),
String.valueOf(chunk.chunkIndex())
);
return sha256(raw);
}
稳定ID便于:
- Upsert;
- 删除;
- 重试;
- 引用;
- 一致性校验。
五、计算文件和Chunk Hash
public String sha256(String content) {
try {
MessageDigest digest =
MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(
content.getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(bytes);
}
catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
解析前先比较文件Hash:
相同
→ 跳过整个文档
不同
→ 解析并比较Chunk Hash
六、差异模型
public record ChunkDiff(
List added,
List modified,
List unchanged,
List removed
) {
}
比较逻辑:
public ChunkDiff diff(
List oldChunks,
List newChunks
) {
Map oldByPath =
oldChunks.stream()
.collect(Collectors.toMap(
this::logicalChunkKey,
Function.identity()
));
Map newByPath =
newChunks.stream()
.collect(Collectors.toMap(
this::logicalChunkKey,
Function.identity()
));
// 根据逻辑位置与contentHash分类
// 示例中省略集合拼装细节
return calculateDiff(oldByPath, newByPath);
}
逻辑Chunk Key:
section_path + chunk_index
如果分块策略变化,应提升:
chunking_version
并执行全量重建,而不是错误复用旧向量。
七、Embedding网关
public interface EmbeddingGateway {
List embed(List texts);
}
Spring AI实现:
@Component
public class SpringAiEmbeddingGateway
implements EmbeddingGateway {
private final EmbeddingModel embeddingModel;
public SpringAiEmbeddingGateway(
EmbeddingModel embeddingModel
) {
this.embeddingModel = embeddingModel;
}
@Override
public List embed(
List texts
) {
return texts.stream()
.map(embeddingModel::embed)
.toList();
}
}
生产环境应批量调用并限制:
- 批大小;
- 并发;
- 超时;
- 重试;
- Token;
- 单任务成本。
八、Qdrant Payload设计
{
"tenant_id": "T001",
"logical_document_id": "TRAVEL-POLICY",
"version_id": "V4",
"source_version": 4,
"status": "READY",
"section_path": "4.2",
"content_hash": "...",
"embedding_model": "...",
"chunking_version": "v2"
}
生产检索只允许:
status = EFFECTIVE
九、增量索引核心服务
@Service
public class IncrementalIndexService {
private final DocumentRepository documents;
private final DocumentParser parser;
private final EmbeddingGateway embeddings;
private final ChunkVectorRepository vectors;
private final VersionSwitchService switchService;
@Transactional
public IndexResult index(IndexCommand command) {
DocumentVersion version =
documents.createVersion(command);
try {
documents.updateStatus(
version.versionId(),
VersionStatus.PARSING
);
List newChunks =
parser.parse(command.file());
List oldChunks =
documents.findEffectiveChunks(
command.logicalDocumentId()
);
ChunkDiff diff = diff(oldChunks, newChunks);
documents.updateStatus(
version.versionId(),
VersionStatus.INDEXING
);
indexChangedChunks(version, diff);
verify(version, newChunks.size());
documents.updateStatus(
version.versionId(),
VersionStatus.READY
);
switchService.activate(version);
return IndexResult.success(
version.versionId(),
diff
);
}
catch (RuntimeException exception) {
documents.markFailed(
version.versionId(),
exception.getMessage()
);
throw exception;
}
}
}
真正项目中不要将长时间Embedding放在单个数据库事务内。上面主要展示流程,生产实现应使用任务状态和短事务。
十、批量写入Qdrant
private void indexChangedChunks(
DocumentVersion version,
ChunkDiff diff
) {
List changed =
Stream.concat(
diff.added().stream(),
diff.modified().stream()
).toList();
for (
List batch
: BatchUtils.partition(changed, 64)
) {
List vectorBatch =
embeddings.embed(
batch.stream()
.map(KnowledgeChunk::content)
.toList()
);
vectors.upsertReady(
version,
batch,
vectorBatch
);
}
}
写入状态先使用:
READY
不要直接设置EFFECTIVE。
十一、版本切换
@Service
public class VersionSwitchService {
@Transactional
public void activate(DocumentVersion version) {
repository.expireCurrentVersion(
version.logicalDocumentId()
);
repository.activateVersion(
version.versionId()
);
vectorRepository.updateStatus(
version.logicalDocumentId(),
version.versionId(),
"EFFECTIVE"
);
cacheVersionRepository.increment(
version.logicalDocumentId()
);
}
}
理想情况下,数据库状态和向量Payload更新需要设计补偿机制,因为它们不能参与同一个本地事务。
可以采用:
状态机
+Outbox
+幂等补偿任务
十二、删除旧版本
版本切换后先逻辑失效:
旧版本 → EXPIRED
再异步清理:
public void cleanupExpired(
String logicalDocumentId,
Duration retention
) {
List expiredVersions =
repository.findExpiredBefore(
logicalDocumentId,
Instant.now().minus(retention)
);
for (String versionId : expiredVersions) {
vectorRepository.deleteVersion(versionId);
}
}
十三、失败恢复
索引任务记录:
task_id
version_id
stage
batch_no
retry_count
error_message
updated_at
恢复时从失败批次继续,而不是重跑全部文档。
重复写入依赖稳定Point ID,所以Upsert是幂等的。
十四、完整性校验
private void verify(
DocumentVersion version,
int expectedCount
) {
long actual = vectors.countByVersion(
version.versionId()
);
if (actual != expectedCount) {
throw new IllegalStateException(
"Chunk数量不一致:expected="
+ expectedCount
+ ", actual="
+ actual
);
}
}
还可以抽样验证:
- 文本Hash;
- 向量维度;
- Payload字段;
- 检索结果;
- 引用映射。
十五、查询过滤
must:
tenant_id = 当前租户
status = EFFECTIVE
不要只按logical_document_id查询,也不要让前端直接传任意tenantId。
租户信息应来自认证上下文。
十六、接口设计
@RestController
@RequestMapping("/api/knowledge-index")
public class KnowledgeIndexController {
private final IndexTaskService tasks;
@PostMapping
public IndexTaskResponse submit(
@RequestBody IndexRequest request
) {
return tasks.submit(request);
}
@GetMapping("/{taskId}")
public IndexTaskStatus status(
@PathVariable String taskId
) {
return tasks.status(taskId);
}
}
大文档使用异步任务,不要让HTTP连接等待整个Embedding过程。
十七、监控指标
index_task_success_rate
index_task_duration
changed_chunk_count
embedding_reuse_rate
embedding_batch_failure_count
index_version_switch_failure_count
ready_version_age
stale_effective_version_count
source_to_searchable_latency
十八、生产环境继续补齐
- 分布式锁;
- 同一文档并发更新;
- 任务队列;
- 断点续跑;
- 限流;
- 成本预算;
- 回归评测;
- 删除传播;
- 多Collection切换;
- Embedding模型迁移。
总结
可恢复的RAG增量索引服务需要同时具备:
稳定ID
+文件与Chunk Hash
+差异计算
+批量Embedding
+READY/EFFECTIVE状态
+原子版本切换
+幂等重试
+完整性验证
只做“新文件重新Embedding并Upsert”还不足以支撑企业知识库长期运行。
延伸阅读
如果你正在关注企业级 AI 应用、RAG、Agent、MCP 与大模型工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。