mateclaw/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiChunkService.java

313 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.dto.WikiChunkDraft;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/**
* Wiki chunk 服务
* <p>
* RFC-013 最小切片chunk 持久化 + hash 级增量对账。
* <ul>
* <li>{@link #persistChunks} — 切分后一次性入库,返回 chunk ID 列表供后续流程使用</li>
* <li>{@link #reconcile} — 增量对账hash 相同的 chunk 保留(含 embeddinghash 不同的重建</li>
* <li>{@link #deleteByRawId} — 材料删除时级联清理</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiChunkService {
private final WikiChunkMapper chunkMapper;
/**
* 将文本切片列表持久化为 chunk 记录。
* <p>
* 如果该 rawId 已有 chunk 记录,走 {@link #reconcile} 增量对账;否则全量插入。
*
* @param kbId 知识库 ID
* @param rawId 原始材料 ID
* @param chunks 切分后的文本列表(有序)
* @param offsets 每个 chunk 对应的 [startOffset, endOffset] 数组
* @return 持久化后的 chunk ID 列表(与 chunks 同序)
*/
@Transactional
public List<Long> persistChunks(Long kbId, Long rawId, List<String> chunks, List<int[]> offsets) {
List<WikiChunkEntity> existing = listByRawId(rawId);
if (existing.isEmpty()) {
// 全量插入
return insertAll(kbId, rawId, chunks, offsets);
}
// 增量对账
return reconcile(kbId, rawId, chunks, offsets, existing);
}
/**
* RFC-051 PR-1a: persist chunks along with their structural metadata
* (page number, token count, header breadcrumb, source section).
* <p>
* Behaves like {@link #persistChunks(Long, Long, List, List)} — full insert
* when no existing chunks for the raw, otherwise hash-based reconcile so
* unchanged chunks keep their embeddings. Reconcile updates the metadata
* columns on retained rows so re-running ingest with a richer normalizer
* can fill in fields that were null on the previous pass.
*
* @param drafts chunks-to-persist with metadata (ordered, ordinal == index)
* @return persisted chunk IDs (same order as {@code drafts})
*/
@Transactional
public List<Long> persistChunks(Long kbId, Long rawId, List<WikiChunkDraft> drafts) {
List<WikiChunkEntity> existing = listByRawId(rawId);
if (existing.isEmpty()) {
return insertAllDrafts(kbId, rawId, drafts);
}
return reconcileDrafts(kbId, rawId, drafts, existing);
}
private List<Long> insertAllDrafts(Long kbId, Long rawId, List<WikiChunkDraft> drafts) {
List<Long> ids = new ArrayList<>(drafts.size());
for (int i = 0; i < drafts.size(); i++) {
WikiChunkDraft draft = drafts.get(i);
String hash = computeHash(draft.content());
WikiChunkEntity entity = buildEntityFromDraft(kbId, rawId, i, draft, hash);
chunkMapper.insert(entity);
ids.add(entity.getId());
}
log.info("[WikiChunk] Inserted {} drafts for raw={}", drafts.size(), rawId);
return ids;
}
private List<Long> reconcileDrafts(Long kbId, Long rawId, List<WikiChunkDraft> drafts,
List<WikiChunkEntity> existing) {
Map<Integer, WikiChunkEntity> oldByOrdinal = new HashMap<>();
for (WikiChunkEntity e : existing) {
oldByOrdinal.put(e.getOrdinal(), e);
}
List<Long> resultIds = new ArrayList<>(drafts.size());
Set<Long> retainedIds = new HashSet<>();
int retained = 0, rebuilt = 0;
for (int i = 0; i < drafts.size(); i++) {
WikiChunkDraft draft = drafts.get(i);
String hash = computeHash(draft.content());
WikiChunkEntity old = oldByOrdinal.get(i);
if (old != null && hash.equals(old.getContentHash())) {
// Same content: keep existing row (and its embedding). Refresh offsets and
// metadata so a re-run with a smarter normalizer can fill gaps.
boolean changed = false;
if (!Objects.equals(old.getStartOffset(), draft.startOffset())) {
old.setStartOffset(draft.startOffset()); changed = true;
}
if (!Objects.equals(old.getEndOffset(), draft.endOffset())) {
old.setEndOffset(draft.endOffset()); changed = true;
}
if (!Objects.equals(old.getPageNumber(), draft.pageNumber())) {
old.setPageNumber(draft.pageNumber()); changed = true;
}
if (!Objects.equals(old.getTokenCount(), draft.tokenCount())) {
old.setTokenCount(draft.tokenCount()); changed = true;
}
if (!Objects.equals(old.getHeaderBreadcrumb(), draft.headerBreadcrumb())) {
old.setHeaderBreadcrumb(draft.headerBreadcrumb()); changed = true;
}
if (!Objects.equals(old.getSourceSection(), draft.sourceSection())) {
old.setSourceSection(draft.sourceSection()); changed = true;
}
if (changed) {
chunkMapper.updateById(old);
}
resultIds.add(old.getId());
retainedIds.add(old.getId());
retained++;
} else {
WikiChunkEntity entity = buildEntityFromDraft(kbId, rawId, i, draft, hash);
chunkMapper.insert(entity);
resultIds.add(entity.getId());
rebuilt++;
}
}
int deleted = 0;
for (WikiChunkEntity old : existing) {
if (!retainedIds.contains(old.getId()) && !resultIds.contains(old.getId())) {
chunkMapper.deleteById(old.getId());
deleted++;
}
}
log.info("[WikiChunk] Reconciled drafts raw={}: retained={}, rebuilt={}, deleted={}",
rawId, retained, rebuilt, deleted);
return resultIds;
}
private WikiChunkEntity buildEntityFromDraft(Long kbId, Long rawId, int ordinal,
WikiChunkDraft draft, String hash) {
WikiChunkEntity entity = new WikiChunkEntity();
entity.setKbId(kbId);
entity.setRawId(rawId);
entity.setOrdinal(ordinal);
entity.setContent(draft.content());
entity.setCharCount(draft.content().length());
entity.setStartOffset(draft.startOffset());
entity.setEndOffset(draft.endOffset());
entity.setContentHash(hash);
entity.setPageNumber(draft.pageNumber());
entity.setTokenCount(draft.tokenCount());
entity.setHeaderBreadcrumb(draft.headerBreadcrumb());
entity.setSourceSection(draft.sourceSection());
return entity;
}
/**
* 增量对账:比对 hash保留不变的 chunk保护未来的 embedding重建变化的。
*
* @return 对账后的 chunk ID 列表(与 chunks 同序)
*/
private List<Long> reconcile(Long kbId, Long rawId, List<String> chunks, List<int[]> offsets,
List<WikiChunkEntity> existing) {
// 旧 chunk 按 ordinal 索引
Map<Integer, WikiChunkEntity> oldByOrdinal = new HashMap<>();
for (WikiChunkEntity e : existing) {
oldByOrdinal.put(e.getOrdinal(), e);
}
List<Long> resultIds = new ArrayList<>(chunks.size());
Set<Long> retainedIds = new HashSet<>();
int retained = 0, rebuilt = 0;
for (int i = 0; i < chunks.size(); i++) {
String text = chunks.get(i);
String hash = computeHash(text);
int[] offset = offsets.get(i);
WikiChunkEntity old = oldByOrdinal.get(i);
if (old != null && hash.equals(old.getContentHash())) {
// hash 相同 → 保留embedding 等附加数据不丢)
// 但更新 offset材料可能在其他位置变了导致偏移变化
if (!old.getStartOffset().equals(offset[0]) || !old.getEndOffset().equals(offset[1])) {
old.setStartOffset(offset[0]);
old.setEndOffset(offset[1]);
chunkMapper.updateById(old);
}
resultIds.add(old.getId());
retainedIds.add(old.getId());
retained++;
} else {
// hash 不同或 ordinal 超出旧范围 → 新建
WikiChunkEntity entity = buildEntity(kbId, rawId, i, text, hash, offset);
chunkMapper.insert(entity);
resultIds.add(entity.getId());
rebuilt++;
}
}
// 删除多余的旧 chunk数量缩减的情况
int deleted = 0;
for (WikiChunkEntity old : existing) {
if (!retainedIds.contains(old.getId()) && !resultIds.contains(old.getId())) {
chunkMapper.deleteById(old.getId());
deleted++;
}
}
log.info("[WikiChunk] Reconciled raw={}: retained={}, rebuilt={}, deleted={}",
rawId, retained, rebuilt, deleted);
return resultIds;
}
/**
* 全量插入
*/
private List<Long> insertAll(Long kbId, Long rawId, List<String> chunks, List<int[]> offsets) {
List<Long> ids = new ArrayList<>(chunks.size());
for (int i = 0; i < chunks.size(); i++) {
String text = chunks.get(i);
String hash = computeHash(text);
int[] offset = offsets.get(i);
WikiChunkEntity entity = buildEntity(kbId, rawId, i, text, hash, offset);
chunkMapper.insert(entity);
ids.add(entity.getId());
}
log.info("[WikiChunk] Inserted {} chunks for raw={}", chunks.size(), rawId);
return ids;
}
public List<WikiChunkEntity> listByRawId(Long rawId) {
return chunkMapper.selectList(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getRawId, rawId)
.orderByAsc(WikiChunkEntity::getOrdinal));
}
public List<WikiChunkEntity> listByKbId(Long kbId) {
return chunkMapper.selectList(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getKbId, kbId)
.orderByAsc(WikiChunkEntity::getRawId)
.orderByAsc(WikiChunkEntity::getOrdinal));
}
@Transactional
public void deleteByRawId(Long rawId) {
int deleted = chunkMapper.delete(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getRawId, rawId));
if (deleted > 0) {
log.info("[WikiChunk] Deleted {} chunks for raw={}", deleted, rawId);
}
}
@Transactional
public void deleteByKbId(Long kbId) {
int deleted = chunkMapper.delete(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getKbId, kbId));
if (deleted > 0) {
log.info("[WikiChunk] Deleted {} chunks for kbId={}", deleted, kbId);
}
}
// ==================== Helpers ====================
private WikiChunkEntity buildEntity(Long kbId, Long rawId, int ordinal, String text, String hash, int[] offset) {
WikiChunkEntity entity = new WikiChunkEntity();
entity.setKbId(kbId);
entity.setRawId(rawId);
entity.setOrdinal(ordinal);
entity.setContent(text);
entity.setCharCount(text.length());
entity.setStartOffset(offset[0]);
entity.setEndOffset(offset[1]);
entity.setContentHash(hash);
return entity;
}
private String computeHash(String content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
log.warn("[WikiChunk] Hash computation failed: {}", e.getMessage());
return "HASH_ERROR_" + content.length();
}
}
}