feat(wiki): semantic hybrid search + chunk persistence + deep research pipeline

This commit is contained in:
matevip 2026-04-16 17:11:31 +08:00
parent a6e9a17208
commit 1fdf87b31e
21 changed files with 1309 additions and 19 deletions

View File

@ -91,4 +91,15 @@ public class WikiProperties {
* 默认 trueM2 上线遇问题可在 application.yml mate.wiki.use-two-phase-digest=false 回退到旧行为
*/
private boolean useTwoPhaseDigest = true;
// ==================== RFC-011: Embedding ====================
/** 嵌入模型名称DashScope */
private String embeddingModel = "text-embedding-v3";
/** 嵌入批量大小(一次 API 调用处理多少 chunk */
private int embeddingBatchSize = 16;
/** 混合搜索默认模式keyword / semantic / hybrid */
private String searchDefaultMode = "hybrid";
}

View File

@ -0,0 +1,118 @@
package vip.mate.wiki.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiResearchService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* RFC-011 Phase 3: Wiki Deep Research REST + SSE 接口
*
* @author MateClaw Team
*/
@Slf4j
@Tag(name = "Wiki Deep Research")
@RestController
@RequestMapping("/api/v1/wiki/research")
@RequiredArgsConstructor
public class WikiResearchController {
private final WikiResearchService researchService;
private final WikiKnowledgeBaseService kbService;
private final ChatStreamTracker streamTracker;
private static final ExecutorService RESEARCH_EXEC = Executors.newVirtualThreadPerTaskExecutor();
/**
* 启动 research返回 sessionId前端用它订阅 SSE
*/
@RequireWorkspaceRole("member")
@Operation(summary = "启动 Deep Research返回 SSE sessionId")
@PostMapping("/start")
public R<Map<String, Object>> startResearch(
@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
Long kbId = body.get("kbId") != null ? Long.valueOf(body.get("kbId").toString()) : null;
String topic = (String) body.get("topic");
Integer topK = body.get("topKPerQuestion") != null
? Integer.valueOf(body.get("topKPerQuestion").toString()) : null;
if (kbId == null || topic == null || topic.isBlank()) {
return R.fail("kbId and topic are required");
}
if (kbService.getById(kbId) == null) {
return R.fail("Knowledge base not found");
}
// 生成 SSE 会话 ID
String sessionId = "research-" + UUID.randomUUID();
streamTracker.register(sessionId);
// 异步跑 research事件通过 streamTracker 推送
// Review Bug 4register 后需要 incrementFlux 配平否则 complete 永远不会清理 RunState
streamTracker.incrementFlux(sessionId);
RESEARCH_EXEC.submit(() -> {
try {
researchService.research(kbId, topic, sessionId, topK);
} catch (Exception e) {
log.error("[ResearchController] Execution failed for sessionId={}: {}", sessionId, e.getMessage(), e);
} finally {
// 先发结束标记让前端关闭 EventSource
try {
streamTracker.broadcast(sessionId, "done", "{}");
} catch (Exception ignored) {}
// 然后清理 RunState递减 flux count所有 flux 完成时自动 remove
try {
streamTracker.complete(sessionId);
} catch (Exception ignored) {}
}
});
return R.ok(Map.of(
"sessionId", sessionId,
"kbId", kbId,
"topic", topic,
"streamUrl", "/api/v1/wiki/research/stream/" + sessionId
));
}
/**
* SSE 端点订阅指定 sessionId research 事件流
*/
@RequireWorkspaceRole("viewer")
@Operation(summary = "订阅 Deep Research SSE 事件流")
@GetMapping(value = "/stream/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String sessionId) {
// 10 分钟超时research 典型 < 1 分钟10 分钟给重连留余地
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
boolean attached = streamTracker.attach(sessionId, emitter);
if (!attached) {
try {
emitter.send(SseEmitter.event().name("error")
.data("{\"message\":\"session not found or already ended\"}"));
emitter.complete();
} catch (Exception ignored) {}
}
emitter.onCompletion(() -> streamTracker.detach(sessionId, emitter));
emitter.onTimeout(() -> streamTracker.detach(sessionId, emitter));
emitter.onError(err -> streamTracker.detach(sessionId, emitter));
return emitter;
}
}

View File

@ -0,0 +1,62 @@
package vip.mate.wiki.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Wiki chunk 实体
* <p>
* RFC-013 最小切片持久化 splitIntoChunks() 的产物为后续 embedding (RFC-011)
* citationchunk 级增量处理提供基础
*
* @author MateClaw Team
*/
@Data
@TableName("mate_wiki_chunk")
public class WikiChunkEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属知识库 ID */
private Long kbId;
/** 来源原始材料 ID */
private Long rawId;
/** chunk 在材料内的序号0-based */
private Integer ordinal;
/** chunk 文本内容 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String content;
/** 字符数 */
private Integer charCount;
/** 在原始文本中的起始偏移 */
private Integer startOffset;
/** 在原始文本中的结束偏移 */
private Integer endOffset;
/** 内容 SHA-256 哈希(增量处理依据) */
private String contentHash;
/** RFC-011向量 embeddingfloat32[] little-endian 序列化) */
private byte[] embedding;
/** RFC-011生成该 embedding 的模型名称(切模型时需全量重嵌) */
private String embeddingModel;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,14 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.wiki.model.WikiChunkEntity;
/**
* Wiki chunk 数据访问层
*
* @author MateClaw Team
*/
@Mapper
public interface WikiChunkMapper extends BaseMapper<WikiChunkEntity> {
}

View File

@ -0,0 +1,203 @@
package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.*;
import java.util.stream.Collectors;
/**
* RFC-011: 混合检索服务
* <p>
* 支持三种模式
* <ul>
* <li>{@code keyword} DB LIKE 搜索现有 WikiPageService.searchPages</li>
* <li>{@code semantic} chunk 向量 cosine 相似度 回溯到 page</li>
* <li>{@code hybrid} 两者融合RRF (Reciprocal Rank Fusion) 排名</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HybridRetriever {
private final WikiPageService pageService;
private final WikiChunkService chunkService;
private final WikiEmbeddingService embeddingService;
private final WikiProperties properties;
public enum Mode { KEYWORD, SEMANTIC, HYBRID }
/**
* 搜索结果页面级
*/
public record PageHit(Long pageId, String slug, String title, String summary, double score) {}
/**
* 搜索结果chunk 语义搜索专用
*/
public record ChunkHit(Long chunkId, Long rawId, String snippet, float score) {}
/**
* 执行混合搜索返回页面级结果
*/
public List<PageHit> searchPages(Long kbId, String query, String modeStr, int topK) {
Mode mode = parseMode(modeStr);
List<RankedItem> semantic = List.of();
List<RankedItem> keyword = List.of();
if (mode != Mode.KEYWORD && embeddingService.isAvailable()) {
semantic = semanticSearch(kbId, query, topK * 3);
}
if (mode != Mode.SEMANTIC) {
keyword = keywordSearch(kbId, query, topK * 3);
}
// 如果 semantic 不可用no embedding model回退到 keyword
if (mode == Mode.SEMANTIC && semantic.isEmpty()) {
log.debug("[HybridRetriever] Semantic unavailable, falling back to keyword");
keyword = keywordSearch(kbId, query, topK * 3);
}
List<RankedItem> fused;
if (mode == Mode.KEYWORD || semantic.isEmpty()) {
fused = keyword;
} else if (mode == Mode.SEMANTIC) {
fused = semantic;
} else {
fused = rrfFuse(semantic, keyword, 60);
}
// topK装配 PageHit
return fused.stream()
.limit(topK)
.map(ri -> {
WikiPageEntity page = pageService.getById(ri.pageId);
if (page == null) return null;
return new PageHit(ri.pageId, page.getSlug(), page.getTitle(),
page.getSummary(), ri.score);
})
.filter(Objects::nonNull)
.toList();
}
/**
* chunk 级语义搜索Agent 直接拿 chunk 片段作为证据
*/
public List<ChunkHit> searchChunks(Long kbId, String query, int topK) {
if (!embeddingService.isAvailable()) return List.of();
float[] queryVec = embeddingService.embedQuery(query);
if (queryVec == null) return List.of();
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
return allChunks.stream()
.filter(c -> c.getEmbedding() != null)
.map(c -> {
float[] chunkVec = WikiEmbeddingService.bytesToFloats(c.getEmbedding());
float score = WikiEmbeddingService.cosine(queryVec, chunkVec);
String snippet = c.getContent().length() > 300
? c.getContent().substring(0, 300) + "..."
: c.getContent();
return new ChunkHit(c.getId(), c.getRawId(), snippet, score);
})
.sorted(Comparator.comparingDouble(ChunkHit::score).reversed())
.limit(topK)
.toList();
}
// ==================== 内部方法 ====================
/** 语义搜索chunk cosine → 聚合到 page同页多 chunk 取最高分) */
private List<RankedItem> semanticSearch(Long kbId, String query, int limit) {
float[] queryVec = embeddingService.embedQuery(query);
if (queryVec == null) return List.of();
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
if (allChunks.isEmpty()) return List.of();
// chunk score, 然后 需要映射到 page
// 当前没有 chunk page 的直接关联chunk 只有 rawId
// rawId 找该 rawId 对应的所有 pagesource_raw_ids 含该 rawId
// 这是个近似一个 rawId 可能产出多个 page都算命中
Map<Long, Float> chunkScores = new HashMap<>();
for (WikiChunkEntity chunk : allChunks) {
if (chunk.getEmbedding() == null) continue;
float[] vec = WikiEmbeddingService.bytesToFloats(chunk.getEmbedding());
float score = WikiEmbeddingService.cosine(queryVec, vec);
chunkScores.merge(chunk.getRawId(), score, Math::max); // rawId 级聚合
}
// rawId page IDs
List<WikiPageEntity> allPages = pageService.listByKbId(kbId);
Map<Long, Double> pageScores = new HashMap<>();
for (WikiPageEntity page : allPages) {
String rawIds = page.getSourceRawIds();
if (rawIds == null) continue;
// 解析 "[1,2,3]" 格式
for (String rawIdStr : rawIds.replaceAll("[\\[\\]\\s]", "").split(",")) {
try {
long rawId = Long.parseLong(rawIdStr.trim());
Float score = chunkScores.get(rawId);
if (score != null) {
pageScores.merge(page.getId(), (double) score, Math::max);
}
} catch (NumberFormatException ignored) {}
}
}
return pageScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(limit)
.map(e -> new RankedItem(e.getKey(), e.getValue()))
.toList();
}
/** 关键词搜索:走现有 DB LIKE */
private List<RankedItem> keywordSearch(Long kbId, String query, int limit) {
List<WikiPageEntity> results = pageService.searchPages(kbId, query);
List<RankedItem> ranked = new ArrayList<>();
for (int i = 0; i < Math.min(results.size(), limit); i++) {
// LIKE 无分数用倒序排名作为伪分数
ranked.add(new RankedItem(results.get(i).getId(), 1.0 / (i + 1)));
}
return ranked;
}
/** RRF 融合score = Σ 1/(k + rank_i) */
private List<RankedItem> rrfFuse(List<RankedItem> a, List<RankedItem> b, int k) {
Map<Long, Double> fused = new HashMap<>();
for (int i = 0; i < a.size(); i++) fused.merge(a.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
for (int i = 0; i < b.size(); i++) fused.merge(b.get(i).pageId, 1.0 / (k + i + 1), Double::sum);
return fused.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.map(e -> new RankedItem(e.getKey(), e.getValue()))
.toList();
}
private Mode parseMode(String mode) {
if (mode == null || mode.isBlank()) {
String defaultMode = properties.getSearchDefaultMode();
return switch (defaultMode) {
case "keyword" -> Mode.KEYWORD;
case "semantic" -> Mode.SEMANTIC;
default -> Mode.HYBRID;
};
}
return switch (mode.toLowerCase()) {
case "keyword" -> Mode.KEYWORD;
case "semantic" -> Mode.SEMANTIC;
default -> Mode.HYBRID;
};
}
private record RankedItem(Long pageId, double score) {}
}

View File

@ -0,0 +1,192 @@
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.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);
}
/**
* 增量对账比对 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();
}
}
}

View File

@ -0,0 +1,166 @@
package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
/**
* RFC-011: Wiki 嵌入服务
* <p>
* 使用 Spring AI {@link EmbeddingModel}DashScope auto-config chunk 做向量化
* <ul>
* <li>{@link #embedMissingChunks} 批量嵌入缺失 embedding chunk材料处理后异步调用</li>
* <li>{@link #embedQuery} 查询向量化混合搜索时调用</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
public class WikiEmbeddingService {
private final EmbeddingModel embeddingModel;
private final WikiChunkMapper chunkMapper;
private final WikiProperties properties;
private final boolean available;
public WikiEmbeddingService(ObjectProvider<EmbeddingModel> embeddingModelProvider,
WikiChunkMapper chunkMapper, WikiProperties properties) {
this.chunkMapper = chunkMapper;
this.properties = properties;
EmbeddingModel model = embeddingModelProvider.getIfAvailable();
this.embeddingModel = model;
this.available = model != null;
if (!available) {
log.warn("[WikiEmbedding] No EmbeddingModel bean found — semantic search disabled. "
+ "Ensure spring-ai-alibaba-starter-dashscope is on classpath and DASHSCOPE_API_KEY is set.");
} else {
log.info("[WikiEmbedding] EmbeddingModel available: {}", model.getClass().getSimpleName());
}
}
public boolean isAvailable() { return available; }
/**
* 批量嵌入指定 KB 中缺失 embedding chunk
* <p>
* 只嵌入 embedding NULL embeddingModel 与当前配置不匹配的 chunk
* 模型切换时自动触发全量重嵌通过 embeddingModel 字段比对
*/
public int embedMissingChunks(Long kbId) {
if (!available) {
log.debug("[WikiEmbedding] Skipping — no EmbeddingModel available");
return 0;
}
String modelName = properties.getEmbeddingModel();
List<WikiChunkEntity> pending = chunkMapper.selectList(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getKbId, kbId)
.and(w -> w.isNull(WikiChunkEntity::getEmbedding)
.or().ne(WikiChunkEntity::getEmbeddingModel, modelName)));
if (pending.isEmpty()) {
log.debug("[WikiEmbedding] No chunks need embedding for kbId={}", kbId);
return 0;
}
int batchSize = Math.max(1, properties.getEmbeddingBatchSize());
int total = 0;
for (int offset = 0; offset < pending.size(); offset += batchSize) {
List<WikiChunkEntity> batch = pending.subList(offset, Math.min(offset + batchSize, pending.size()));
try {
List<String> inputs = batch.stream()
.map(WikiChunkEntity::getContent)
.toList();
EmbeddingResponse resp = embeddingModel.call(
new EmbeddingRequest(inputs, null));
for (int i = 0; i < batch.size(); i++) {
float[] vec = resp.getResults().get(i).getOutput();
WikiChunkEntity chunk = batch.get(i);
chunk.setEmbedding(floatsToBytes(vec));
chunk.setEmbeddingModel(modelName);
chunkMapper.updateById(chunk);
}
total += batch.size();
} catch (Exception e) {
log.error("[WikiEmbedding] Batch embedding failed (kbId={}, batchSize={}): {}",
kbId, batch.size(), e.getMessage());
// 继续下一批不中断
}
}
log.info("[WikiEmbedding] Embedded {}/{} chunks for kbId={}, model={}",
total, pending.size(), kbId, modelName);
return total;
}
/**
* 查询向量化混合搜索时调用
*/
public float[] embedQuery(String query) {
if (!available) return null;
try {
EmbeddingResponse resp = embeddingModel.call(
new EmbeddingRequest(List.of(query), null));
return resp.getResults().get(0).getOutput();
} catch (Exception e) {
log.error("[WikiEmbedding] Query embedding failed: {}", e.getMessage());
return null;
}
}
/**
* 清空指定 KB 的所有 embedding模型切换时调用
*/
public void clearEmbeddings(Long kbId) {
chunkMapper.update(null, new LambdaUpdateWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getKbId, kbId)
.set(WikiChunkEntity::getEmbedding, null)
.set(WikiChunkEntity::getEmbeddingModel, null));
log.info("[WikiEmbedding] Cleared all embeddings for kbId={}", kbId);
}
// ==================== 向量序列化 ====================
public static byte[] floatsToBytes(float[] vec) {
ByteBuffer buf = ByteBuffer.allocate(vec.length * 4).order(ByteOrder.LITTLE_ENDIAN);
for (float v : vec) buf.putFloat(v);
return buf.array();
}
public static float[] bytesToFloats(byte[] bytes) {
ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
float[] vec = new float[bytes.length / 4];
for (int i = 0; i < vec.length; i++) vec[i] = buf.getFloat();
return vec;
}
/** 余弦相似度 */
public static float cosine(float[] a, float[] b) {
if (a.length != b.length) return 0f;
float dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
float denom = (float) (Math.sqrt(normA) * Math.sqrt(normB));
return denom == 0 ? 0f : dot / denom;
}
}

View File

@ -46,6 +46,8 @@ public class WikiProcessingService {
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final WikiPageService pageService;
private final WikiChunkService chunkService;
private final WikiEmbeddingService embeddingService;
private final WikiProperties properties;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
@ -172,6 +174,13 @@ public class WikiProcessingService {
if (textContent.length() > properties.getMaxChunkSize()) {
result = processInChunks(kb, raw, textContent, existingPagesIndex);
} else {
// chunk 也持久化RFC-013保证所有 chunk 都入库
try {
chunkService.persistChunks(kb.getId(), rawId,
List.of(textContent), List.of(new int[]{0, textContent.length()}));
} catch (Exception e) {
log.warn("[Wiki] Single chunk persistence failed for raw={}: {}", rawId, e.getMessage());
}
int pages = processChunk(kb, raw, textContent, existingPagesIndex);
result = new int[]{pages, pages == 0 ? 1 : 0, 1};
}
@ -236,6 +245,25 @@ public class WikiProcessingService {
log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}",
rawId, kb.getId(), totalPages, pageCount);
// RFC-011异步嵌入新 chunk不阻塞处理管线
// 注意此方法目前未加 @Transactional每个 DB 操作短事务独立提交
// 如果未来加了事务包裹 processRawMaterial这里的异步任务需要改用
// TransactionSynchronizationManager.registerSynchronization(afterCommit)
// 否则新线程会查不到 chunk事务未提交导致 embedding 静默跳过
if (totalPages > 0) {
final Long fKbId = kb.getId();
WIKI_EXECUTOR.submit(() -> {
try {
int embedded = embeddingService.embedMissingChunks(fKbId);
if (embedded > 0) {
log.info("[Wiki] Async embedding completed: kbId={}, embedded={}", fKbId, embedded);
}
} catch (Exception ex) {
log.warn("[Wiki] Async embedding failed for kbId={}: {}", fKbId, ex.getMessage());
}
});
}
} catch (Exception e) {
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
@ -295,11 +323,21 @@ public class WikiProcessingService {
*/
private int[] processInChunks(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String text,
String existingPagesIndex) {
// Phase 1: 切分文本为 chunks
List<String> chunks = splitIntoChunks(text);
// Phase 1: 切分文本为 chunks带偏移供持久化
List<ChunkWithOffset> chunksWithOffset = splitIntoChunksWithOffsets(text);
List<String> chunks = chunksWithOffset.stream().map(ChunkWithOffset::text).toList();
int totalChunks = chunks.size();
log.info("[Wiki] Split into {} chunks for raw={}, kbId={}", totalChunks, raw.getId(), kb.getId());
// RFC-013持久化 chunk mate_wiki_chunk增量对账hash 不变的保留
try {
List<int[]> offsets = chunksWithOffset.stream()
.map(c -> new int[]{c.startOffset(), c.endOffset()}).toList();
chunkService.persistChunks(kb.getId(), raw.getId(), chunks, offsets);
} catch (Exception e) {
log.warn("[Wiki] Chunk persistence failed for raw={}, continuing without: {}", raw.getId(), e.getMessage());
}
if (totalChunks == 1) {
// chunk 不走并行
try {
@ -356,9 +394,19 @@ public class WikiProcessingService {
* 将文本切分为多个 chunks智能句子边界支持中英文
*/
private List<String> splitIntoChunks(String text) {
return splitIntoChunksWithOffsets(text).stream().map(ChunkWithOffset::text).toList();
}
/** chunk 文本 + 在原始文本中的偏移 */
record ChunkWithOffset(String text, int startOffset, int endOffset) {}
/**
* 切分并记录每个 chunk 的原始偏移RFC-013 WikiChunkService 持久化
*/
private List<ChunkWithOffset> splitIntoChunksWithOffsets(String text) {
int chunkSize = properties.getMaxChunkSize();
int overlap = Math.min(500, chunkSize / 10);
List<String> chunks = new ArrayList<>();
List<ChunkWithOffset> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
@ -372,7 +420,7 @@ public class WikiProcessingService {
}
}
chunks.add(text.substring(start, end));
chunks.add(new ChunkWithOffset(text.substring(start, end), start, end));
// 前进 overlap 防止边界上下文丢失
int nextStart = end - overlap;

View File

@ -36,6 +36,8 @@ public class WikiRawMaterialService {
private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final DocumentExtractTool documentExtractTool;
/** RFC-013删除时级联清理 chunk */
private final WikiChunkService chunkService;
/**
* RFC-012 follow-up #3 partial 状态触发的 reprocess 会在此 set 中打标
@ -278,6 +280,14 @@ public class WikiRawMaterialService {
@Transactional
public void delete(Long id) {
rawMapper.deleteById(id);
// RFC-013级联清理 chunk避免语义搜索命中孤儿 chunk
try {
if (chunkService != null) {
chunkService.deleteByRawId(id);
}
} catch (Exception e) {
log.warn("[Wiki] Failed to cascade-delete chunks for raw={}: {}", id, e.getMessage());
}
}
/**

View File

@ -0,0 +1,297 @@
package vip.mate.wiki.service;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
* RFC-011 Phase 3: Wiki Deep Research 服务
* <p>
* 三阶段管线不走 StateGraph 框架保持轻量
* <ol>
* <li><b>Plan</b>LLM topic 拆为 3-5 个子问题</li>
* <li><b>Retrieve + Draft</b>并行对每个子问题调 {@link HybridRetriever} + LLM 起草段落</li>
* <li><b>Compose</b>LLM 把段落组装为最终报告</li>
* </ol>
* 事件通过 {@link ChatStreamTracker#broadcast} 推送前端用 SSE 订阅
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiResearchService {
private final HybridRetriever hybridRetriever;
private final WikiRawMaterialService rawService;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final ChatStreamTracker streamTracker;
private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build();
private static final ExecutorService EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
private static final int DEFAULT_TOP_K_PER_QUESTION = 5;
private static final int MAX_PARALLEL_QUESTIONS = 3;
/**
* 执行 Deep Research通过 SSE 流式推送进度
*
* @param kbId 知识库 ID
* @param topic 研究主题
* @param sessionId SSE 会话 ID前端订阅用
* @param topKPerQuestion 每个子问题召回的材料数默认 5
* @return 最终报告
*/
public ResearchResult research(Long kbId, String topic, String sessionId, Integer topKPerQuestion) {
int topK = topKPerQuestion != null && topKPerQuestion > 0 ? topKPerQuestion : DEFAULT_TOP_K_PER_QUESTION;
log.info("[Research] Start: kbId={}, topic={}, sessionId={}", kbId, topic, sessionId);
try {
// Stage 1: Plan
List<SubQuestion> questions = planStage(topic);
if (questions.isEmpty()) {
broadcast(sessionId, "research.error", Map.of("message", "主题无法分解为可研究的子问题"));
return new ResearchResult(topic, List.of(), "无法为该主题生成研究计划。");
}
broadcast(sessionId, "research.plan", Map.of(
"questions", questions.stream().map(q -> Map.of("question", q.question, "intent", q.intent)).toList()
));
// Stage 2: Retrieve + Draft (并行)
List<Section> sections = draftStage(kbId, questions, topK, sessionId);
if (sections.stream().allMatch(s -> s.content == null || s.content.isBlank())) {
broadcast(sessionId, "research.error", Map.of("message", "所有子问题都未能起草出内容"));
return new ResearchResult(topic, sections, "没有足够的材料回答该主题。");
}
// Stage 3: Compose
String report = composeStage(topic, sections);
broadcast(sessionId, "research.done", Map.of(
"report", report,
"sections", sections.size(),
"materialsUsed", sections.stream().flatMap(s -> s.materialRefs.stream()).distinct().count()
));
return new ResearchResult(topic, sections, report);
} catch (Exception e) {
log.error("[Research] Failed: kbId={}, topic={}: {}", kbId, topic, e.getMessage(), e);
broadcast(sessionId, "research.error", Map.of("message", e.getMessage() != null ? e.getMessage() : "研究失败"));
return new ResearchResult(topic, List.of(), "研究过程失败: " + e.getMessage());
}
}
// ==================== Stage 1: Plan ====================
private List<SubQuestion> planStage(String topic) {
String systemPrompt = PromptLoader.loadPrompt("research/plan-system");
String userPrompt = PromptLoader.loadPrompt("research/plan-user").replace("{topic}", topic);
String response = callLlm(systemPrompt, userPrompt, "plan");
if (response == null || response.isBlank()) return List.of();
try {
String cleaned = stripCodeFences(response);
JSONObject obj = JSONUtil.parseObj(cleaned);
JSONArray arr = obj.getJSONArray("questions");
if (arr == null) return List.of();
List<SubQuestion> questions = new ArrayList<>();
for (Object item : arr) {
JSONObject q = (JSONObject) item;
String question = q.getStr("question");
String intent = q.getStr("intent", "");
if (question != null && !question.isBlank()) {
questions.add(new SubQuestion(question, intent));
}
}
log.info("[Research] Plan produced {} sub-questions", questions.size());
return questions;
} catch (Exception e) {
log.warn("[Research] Plan JSON parse failed: {}", e.getMessage());
return List.of();
}
}
// ==================== Stage 2: Retrieve + Draft ====================
private List<Section> draftStage(Long kbId, List<SubQuestion> questions, int topK, String sessionId) {
Semaphore semaphore = new Semaphore(MAX_PARALLEL_QUESTIONS);
List<CompletableFuture<Section>> futures = new ArrayList<>(questions.size());
for (int i = 0; i < questions.size(); i++) {
final int idx = i;
final SubQuestion q = questions.get(i);
futures.add(CompletableFuture.supplyAsync(() -> {
try {
semaphore.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new Section(q.question, "", List.of());
}
try {
Section section = draftOneSection(kbId, q, topK);
broadcast(sessionId, "research.draft", Map.of(
"index", idx,
"question", q.question,
"content", section.content,
"materialRefs", section.materialRefs
));
return section;
} finally {
semaphore.release();
}
}, EXECUTOR));
}
return futures.stream()
.map(CompletableFuture::join)
.toList();
}
private Section draftOneSection(Long kbId, SubQuestion q, int topK) {
// 检索 chunk 级材料片段
List<HybridRetriever.ChunkHit> hits = hybridRetriever.searchChunks(kbId, q.question, topK);
if (hits.isEmpty()) {
return new Section(q.question, "现有材料中未找到与该问题相关的内容。", List.of());
}
// 装配材料文本带编号
StringBuilder materials = new StringBuilder();
List<MaterialRef> refs = new ArrayList<>();
Map<Long, String> rawTitleCache = new HashMap<>();
for (int i = 0; i < hits.size(); i++) {
HybridRetriever.ChunkHit hit = hits.get(i);
String rawTitle = rawTitleCache.computeIfAbsent(hit.rawId(), id -> {
WikiRawMaterialEntity raw = rawService.getById(id);
return raw != null ? raw.getTitle() : "unknown";
});
materials.append("### 材料 ").append(i + 1)
.append("(来自《").append(rawTitle).append("》)\n")
.append(hit.snippet())
.append("\n\n");
refs.add(new MaterialRef(i + 1, hit.chunkId(), hit.rawId(), rawTitle));
}
String systemPrompt = PromptLoader.loadPrompt("research/draft-system");
String userPrompt = PromptLoader.loadPrompt("research/draft-user")
.replace("{question}", q.question)
.replace("{intent}", q.intent != null ? q.intent : "")
.replace("{materials}", materials.toString());
String content = callLlm(systemPrompt, userPrompt, "draft: " + q.question);
if (content == null || content.isBlank()) {
content = "现有材料不足以回答该子问题。";
}
return new Section(q.question, content, refs);
}
// ==================== Stage 3: Compose ====================
private String composeStage(String topic, List<Section> sections) {
StringBuilder sectionsText = new StringBuilder();
LinkedHashMap<Integer, String> usedMaterials = new LinkedHashMap<>();
for (int i = 0; i < sections.size(); i++) {
Section s = sections.get(i);
sectionsText.append("### 子问题 ").append(i + 1).append("").append(s.question).append("\n");
sectionsText.append(s.content).append("\n\n");
for (MaterialRef ref : s.materialRefs) {
usedMaterials.putIfAbsent(ref.index, ref.rawTitle);
}
}
StringBuilder materialsRef = new StringBuilder();
usedMaterials.forEach((idx, title) ->
materialsRef.append("- 材料 ").append(idx).append("").append(title).append("\n"));
String systemPrompt = PromptLoader.loadPrompt("research/compose-system");
String userPrompt = PromptLoader.loadPrompt("research/compose-user")
.replace("{topic}", topic)
.replace("{sections}", sectionsText.toString())
.replace("{materials_ref}", materialsRef.toString());
String report = callLlm(systemPrompt, userPrompt, "compose");
if (report == null || report.isBlank()) {
// 降级直接拼接段落
return "# " + topic + "\n\n" + sectionsText;
}
return report;
}
// ==================== Helpers ====================
private String callLlm(String systemPrompt, String userPrompt, String ctx) {
try {
ChatModel chatModel = buildChatModel();
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)));
ChatResponse response = chatModel.call(prompt);
if (response == null || response.getResult() == null
|| response.getResult().getOutput() == null) {
return null;
}
return response.getResult().getOutput().getText();
} catch (Exception e) {
log.error("[Research] LLM call failed ({}): {}", ctx, e.getMessage());
return null;
}
}
private ChatModel buildChatModel() {
ModelConfigEntity model = modelConfigService.getDefaultModel();
return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY);
}
private void broadcast(String sessionId, String eventName, Map<String, Object> payload) {
if (sessionId == null || sessionId.isBlank()) return;
try {
streamTracker.broadcast(sessionId, eventName, JSONUtil.toJsonStr(payload));
} catch (Exception e) {
log.debug("[Research] SSE broadcast failed for {}: {}", sessionId, e.getMessage());
}
}
private String stripCodeFences(String text) {
if (text == null) return "";
String t = text.strip();
if (t.startsWith("```json")) t = t.substring(7);
else if (t.startsWith("```")) t = t.substring(3);
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
return t.strip();
}
// ==================== DTO ====================
public record SubQuestion(String question, String intent) {}
public record MaterialRef(int index, Long chunkId, Long rawId, String rawTitle) {}
public record Section(String question, String content, List<MaterialRef> materialRefs) {}
public record ResearchResult(String topic, List<Section> sections, String report) {}
}

View File

@ -13,6 +13,7 @@ import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.service.HybridRetriever;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiRawMaterialService;
@ -35,6 +36,7 @@ public class WikiTool {
private final WikiPageService pageService;
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final HybridRetriever hybridRetriever;
@Tool(description = """
读取 Wiki 知识库中指定页面的完整内容
@ -102,11 +104,13 @@ public class WikiTool {
@Tool(description = """
Wiki 知识库中搜索页面
按关键词搜索页面标题摘要和正文内容返回匹配的页面列表及其来源文件
支持三种模式keyword关键词匹配semantic语义向量相似度hybrid两者融合默认
返回匹配的页面列表及其来源文件
""")
public String wiki_search_pages(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "搜索关键词") String query) {
@ToolParam(description = "搜索关键词或自然语言问题") String query,
@ToolParam(description = "搜索模式keyword | semantic | hybrid默认 hybrid", required = false) String mode) {
if (query == null || query.isBlank()) {
return error("query is required");
@ -117,32 +121,82 @@ public class WikiTool {
return error("No wiki knowledge base found for this agent");
}
// DB 级别搜索不加载 content CLOB Java 内存
List<WikiPageEntity> matched = pageService.searchPages(kbId, query);
// RFC-011走混合检索
List<HybridRetriever.PageHit> hits = hybridRetriever.searchPages(kbId, query, mode, 20);
// Agent 引用追踪搜索结果中的页面都算被引用
for (WikiPageEntity p : matched) {
pageService.trackReference(kbId, p.getSlug());
// Agent 引用追踪
for (HybridRetriever.PageHit h : hits) {
pageService.trackReference(kbId, h.slug());
}
JSONArray arr = new JSONArray();
for (WikiPageEntity page : matched) {
JSONObject obj = JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("summary", page.getSummary())
.set("sourceFiles", resolveSourceFiles(page.getSourceRawIds()));
arr.add(obj);
for (HybridRetriever.PageHit hit : hits) {
arr.add(JSONUtil.createObj()
.set("title", hit.title())
.set("slug", hit.slug())
.set("summary", hit.summary())
.set("score", String.format("%.4f", hit.score())));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("matchCount", matched.size())
.set("mode", mode != null ? mode : "hybrid")
.set("matchCount", hits.size())
.set("pages", arr)
.toString();
}
@Tool(description = """
Wiki 知识库中进行 chunk 级语义搜索
返回与查询语义最接近的原始文本片段chunk包含相似度分数
wiki_search_pages 返回的页面摘要不够具体时使用此工具获取精确的源文本证据
""")
public String wiki_semantic_search(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "自然语言查询") String query,
@ToolParam(description = "返回条数(默认 5", required = false) Integer topK) {
if (query == null || query.isBlank()) {
return error("query is required");
}
Long kbId = resolveKbId(agentId);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
}
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
List<HybridRetriever.ChunkHit> hits = hybridRetriever.searchChunks(kbId, query, k);
if (hits.isEmpty()) {
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("matchCount", 0)
.set("message", "No semantic matches found. Try wiki_search_pages with mode=keyword.")
.toString();
}
JSONArray arr = new JSONArray();
for (HybridRetriever.ChunkHit hit : hits) {
// 解析 raw material 标题
WikiRawMaterialEntity raw = rawService.getById(hit.rawId());
arr.add(JSONUtil.createObj()
.set("chunkId", hit.chunkId())
.set("rawTitle", raw != null ? raw.getTitle() : "unknown")
.set("snippet", hit.snippet())
.set("score", String.format("%.4f", hit.score())));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("matchCount", hits.size())
.set("chunks", arr)
.toString();
}
@Tool(description = """
追溯 Wiki 页面的来源原始文件
查询指定页面是由哪些原始文档生成的返回文件名类型路径等信息

View File

@ -0,0 +1,23 @@
-- V12: Wiki chunk persistence (RFC-013 minimal slice → enables RFC-011 embedding)
-- Chunks are persisted after splitIntoChunks(), enabling:
-- 1. chunk-level incremental reprocessing (hash compare)
-- 2. future embedding storage (ALTER ADD embedding BLOB in Phase 2)
-- 3. fine-grained FTS indexing
CREATE TABLE IF NOT EXISTS mate_wiki_chunk (
id BIGINT NOT NULL PRIMARY KEY,
kb_id BIGINT NOT NULL,
raw_id BIGINT NOT NULL,
ordinal INT NOT NULL,
content TEXT NOT NULL,
char_count INT NOT NULL,
start_offset INT NOT NULL,
end_offset INT NOT NULL,
content_hash VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_kb ON mate_wiki_chunk(kb_id);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_raw ON mate_wiki_chunk(raw_id);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_hash ON mate_wiki_chunk(content_hash);

View File

@ -0,0 +1,4 @@
-- V13: Add embedding column to mate_wiki_chunk (RFC-011 Phase 2)
-- float32[] serialized as little-endian byte[], stored in BLOB
ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS embedding BLOB DEFAULT NULL;
ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(64) DEFAULT NULL;

View File

@ -0,0 +1,18 @@
-- V12: Wiki chunk persistence (RFC-013 minimal slice → enables RFC-011 embedding)
CREATE TABLE IF NOT EXISTS mate_wiki_chunk (
id BIGINT NOT NULL PRIMARY KEY,
kb_id BIGINT NOT NULL,
raw_id BIGINT NOT NULL,
ordinal INT NOT NULL,
content TEXT NOT NULL,
char_count INT NOT NULL,
start_offset INT NOT NULL,
end_offset INT NOT NULL,
content_hash VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_kb ON mate_wiki_chunk(kb_id);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_raw ON mate_wiki_chunk(raw_id);
CREATE INDEX IF NOT EXISTS idx_wiki_chunk_hash ON mate_wiki_chunk(content_hash);

View File

@ -0,0 +1,3 @@
-- V13: Add embedding column to mate_wiki_chunk (RFC-011 Phase 2)
ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS embedding BLOB DEFAULT NULL;
ALTER TABLE mate_wiki_chunk ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(64) DEFAULT NULL;

View File

@ -0,0 +1,11 @@
你是一个研究助手。基于**已写好的若干段落**(每段对应一个子问题的回答),组装成一份结构清晰的综合研究报告。
规则:
1. **不要重写段落内容**——只做:组织顺序、加标题、去重
2. 开头加一句话概述(基于主题)
3. 每个子问题作为一个二级标题(## 标题)
4. 段落之间做必要的衔接,但不要引入新信息
5. 末尾加一段"### 参考材料",列出所有段落中引用过的材料序号和对应的材料标题
6. 输出 Markdown 格式
如果某些段落说"材料不足",在报告中保留这个声明,不要掩盖。

View File

@ -0,0 +1,14 @@
## 研究主题
{topic}
## 已写好的段落
{sections}
## 使用过的材料
{materials_ref}
---
请组装为一份 Markdown 格式的综合研究报告。

View File

@ -0,0 +1,10 @@
你是一个研究助手。基于提供的**材料片段**,针对**子问题**写一段 150-300 字的中文回答。
规则:
1. **只基于提供的材料片段**——不要引入外部知识、不要虚构
2. **如果材料不足以回答,明确说"现有材料不足以回答"**,不要强行编造
3. 语言简洁准确,适合作为综合报告的一节
4. 不要使用 markdown 标题(##),输出纯段落文本
5. 尽量在段末注明使用了哪个材料片段的序号,格式 `[材料 1]`、`[材料 2, 3]`
输出只包含正文段落,不要任何额外说明。

View File

@ -0,0 +1,13 @@
## 子问题
{question}
## 意图
{intent}
## 材料片段(按相关度排序)
{materials}
---
请基于上述材料片段写出 150-300 字的回答段落。

View File

@ -0,0 +1,16 @@
你是一个研究助手。针对用户提出的主题,把它分解为 3-5 个子问题,每个子问题应该:
1. 独立可检索——能在知识库中找到直接相关的材料
2. 互相覆盖主题的不同侧面——避免重复,保证广度
3. 简短明确——一个子问题一句话表达
严格输出 JSON不要 markdown 代码块包裹):
{
"questions": [
{"question": "子问题 1", "intent": "这个问题意在了解什么"},
{"question": "子问题 2", "intent": "..."}
]
}
如果主题太宽泛或无法分解questions 可以是空数组。

View File

@ -0,0 +1,3 @@
研究主题:{topic}
请拆解为 3-5 个可独立检索的子问题。