diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java index 5ec7d38e..4df212e6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -130,6 +130,22 @@ public class WikiProperties { */ private int embeddingMaxChars = 6000; + /** + * Expected embedding-input format version. The authoritative source is + * the builder's {@code CURRENT_INPUT_VERSION} constant; this property + * exists for staged rollouts and ops overrides. + *
+ * Behavior on startup: + *
+ * Set to the embedding input builder's current version on every write. + * NULL signals a legacy content-only embedding from before the builder + * existed and is treated as stale on the next re-embed pass. + */ + private String embeddingTextVersion; + /** RFC-051: source page number (PDF/PPTX) when known; null otherwise. */ private Integer pageNumber; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookup.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookup.java new file mode 100644 index 00000000..1ffe5f3b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookup.java @@ -0,0 +1,15 @@ +package vip.mate.wiki.service; + +/** + * Resolves {@code rawId -> rawTitle} for embedding-time enrichment. + *
+ * Implementations may be naive (one DB hit per call), batch-preloaded for a
+ * given job, or backed by an in-memory snapshot shared with an ingest-scope
+ * page index. Callers must tolerate {@code null} for unknown / deleted ids.
+ */
+@FunctionalInterface
+public interface RawTitleLookup {
+
+ /** @return raw material title, or {@code null} when the id is unknown */
+ String titleFor(Long rawId);
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookups.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookups.java
new file mode 100644
index 00000000..57aef55b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/RawTitleLookups.java
@@ -0,0 +1,42 @@
+package vip.mate.wiki.service;
+
+import vip.mate.wiki.dto.RawTitleRef;
+import vip.mate.wiki.repository.WikiRawMaterialMapper;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Factory helpers for {@link RawTitleLookup}.
+ */
+public final class RawTitleLookups {
+
+ private RawTitleLookups() {}
+
+ /** Lookup that always returns {@code null}; useful for callers without raw context. */
+ public static RawTitleLookup empty() {
+ return id -> null;
+ }
+
+ /** Lookup backed by a pre-resolved map (e.g. from an ingest-scope snapshot). */
+ public static RawTitleLookup of(Map
+ * Naive content-only embeddings make short or context-poor chunks (e.g. a
+ * standalone sentence like "accuracy improved by 12%") near-indistinguishable
+ * in vector space. Prefixing the model input with already-available metadata —
+ * source title, header breadcrumb, source section, page number — preserves
+ * the semantic neighborhood the chunk came from without changing the storage
+ * model.
+ *
+ * Bump {@link #CURRENT_INPUT_VERSION} whenever the prefix format changes. The
+ * embedding pass treats any chunk whose stored {@code embedding_text_version}
+ * differs from the current value as stale and re-embeds it.
+ */
+@Component
+public class WikiEmbeddingInputBuilder {
+
+ /**
+ * Version tag stamped onto every chunk that this builder embeds.
+ * Increment when the prefix format below changes in a way that should
+ * trigger a re-embed pass. The string is opaque; "v1", "v2", ... is fine.
+ */
+ public static final String CURRENT_INPUT_VERSION = "v1";
+
+ /**
+ * Build the embedding input string for a chunk. Metadata fields that are
+ * null or blank are skipped so empty values never produce stray headers.
+ * Falls back to the chunk content alone when no metadata is available.
+ */
+ public String build(WikiChunkEntity chunk, RawTitleLookup lookup) {
+ if (chunk == null) {
+ return "";
+ }
+ String content = chunk.getContent() == null ? "" : chunk.getContent();
+ String prefix = buildPrefix(chunk, lookup);
+ return prefix.isEmpty() ? content : prefix + content;
+ }
+
+ /**
+ * Build only the metadata prefix for a chunk. Useful when callers need to
+ * split content into sub-segments and prepend the prefix to each one so
+ * the metadata participates in every per-segment embedding before pooling.
+ * Returns an empty string when no metadata is available; otherwise ends
+ * with a blank line so the content reads as a separate paragraph.
+ */
+ public String buildPrefix(WikiChunkEntity chunk, RawTitleLookup lookup) {
+ if (chunk == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ String rawTitle = (lookup == null || chunk.getRawId() == null)
+ ? null : lookup.titleFor(chunk.getRawId());
+ appendLine(sb, "Source", rawTitle);
+ appendLine(sb, "Section", chunk.getHeaderBreadcrumb());
+ appendLine(sb, "Subsection", chunk.getSourceSection());
+ if (chunk.getPageNumber() != null) {
+ appendLine(sb, "Page", String.valueOf(chunk.getPageNumber()));
+ }
+ if (sb.length() == 0) {
+ return "";
+ }
+ sb.append('\n');
+ return sb.toString();
+ }
+
+ /** @return the version tag this builder stamps onto each chunk it embeds */
+ public String currentVersion() {
+ return CURRENT_INPUT_VERSION;
+ }
+
+ private static void appendLine(StringBuilder sb, String label, String value) {
+ if (value == null || value.isBlank()) {
+ return;
+ }
+ sb.append(label).append(": ").append(value.strip()).append('\n');
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java
index b4ccf373..3cd55eb9 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java
@@ -2,6 +2,7 @@ package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
@@ -17,11 +18,14 @@ import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
+import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/**
* RFC-011 + Embedding-UI-Config: Wiki 嵌入服务
@@ -42,16 +46,86 @@ import java.util.List;
public class WikiEmbeddingService {
private final WikiChunkMapper chunkMapper;
+ private final WikiRawMaterialMapper rawMaterialMapper;
private final WikiProperties properties;
private final EmbeddingModelFactory factory;
private final ModelConfigService modelConfigService;
private final WikiKnowledgeBaseService kbService;
private final SystemSettingMapper systemSettingMapper;
private final vip.mate.llm.service.ModelProviderService modelProviderService;
+ private final WikiEmbeddingInputBuilder inputBuilder;
/** 系统默认 embedding 模型的 mate_system_setting key */
public static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id";
+ /**
+ * Returns the embedding input format version this service stamps onto
+ * each chunk. The builder's constant is the source of truth; the
+ * {@code mate.wiki.embedding-text-version-current} property exists only
+ * to support ops overrides and is validated against the builder at
+ * startup ({@link #verifyConfiguredInputVersion()}).
+ */
+ public String currentInputVersion() {
+ String configured = properties.getEmbeddingTextVersionCurrent();
+ return (configured == null || configured.isBlank()) ? inputBuilder.currentVersion() : configured.trim();
+ }
+
+ /**
+ * Validate the configured embedding input version against the builder
+ * constant on startup. A blank config is normal (the builder version is
+ * used). A config below the builder is allowed with a WARN so a KB can
+ * be embedded against an older format during a gradual rollback. A
+ * config above the builder fails fast — it almost always means the
+ * config was deployed ahead of the code.
+ */
+ @PostConstruct
+ void verifyConfiguredInputVersion() {
+ String configured = properties.getEmbeddingTextVersionCurrent();
+ if (configured == null || configured.isBlank()) {
+ log.info("[WikiEmbedding] Embedding input version: {} (from builder)", inputBuilder.currentVersion());
+ return;
+ }
+ String builderVersion = inputBuilder.currentVersion();
+ int cmp = compareInputVersions(configured.trim(), builderVersion);
+ if (cmp == 0) {
+ log.info("[WikiEmbedding] Embedding input version: {} (matches builder)", configured);
+ } else if (cmp < 0) {
+ log.warn("[WikiEmbedding] Configured embedding input version {} is older than builder {}; "
+ + "new embeddings will still be stamped with the configured value. "
+ + "Clear mate.wiki.embedding-text-version-current to use the builder default.",
+ configured, builderVersion);
+ } else {
+ throw new IllegalStateException(
+ "Configured embedding input version " + configured + " is newer than builder version "
+ + builderVersion + ". The builder code is older than the deployment config; "
+ + "upgrade the application or clear mate.wiki.embedding-text-version-current.");
+ }
+ }
+
+ /**
+ * Compare version tags of the form {@code v\d+} numerically (so v2 > v10
+ * does not happen). Falls back to case-insensitive string compare when
+ * either side does not match the expected pattern.
+ */
+ static int compareInputVersions(String a, String b) {
+ Integer ai = parseNumericVersion(a);
+ Integer bi = parseNumericVersion(b);
+ if (ai != null && bi != null) {
+ return Integer.compare(ai, bi);
+ }
+ return a.compareToIgnoreCase(b);
+ }
+
+ private static Integer parseNumericVersion(String tag) {
+ if (tag == null || tag.length() < 2) return null;
+ if (tag.charAt(0) != 'v' && tag.charAt(0) != 'V') return null;
+ try {
+ return Integer.parseInt(tag.substring(1));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
/**
* 判断全局是否有可用的 embedding 能力(任何 enabled 的 embedding 模型配置)
*/
@@ -116,8 +190,10 @@ public class WikiEmbeddingService {
/**
* 批量嵌入指定 KB 中缺失 embedding 的 chunk。
*
- * 只嵌入 embedding 为 NULL 或 embeddingModel 与当前解析出的模型不一致的 chunk。
- * 模型切换时自动触发全量重嵌(通过 embedding_model 字段比对)。
+ * Pending criteria: embedding is NULL, the stored embedding_model differs
+ * from the currently-resolved model, or the stored embedding_text_version
+ * differs from the active builder version. Switching the embedding model
+ * or bumping the input format both trigger a full re-embed pass.
*/
public int embedMissingChunks(Long kbId) {
Resolved r = resolveForKb(kbId);
@@ -127,17 +203,21 @@ public class WikiEmbeddingService {
}
String modelName = r.modelName();
+ String inputVersion = currentInputVersion();
List