Эх сурвалжийг харах

feat(embedding): 向量能力支持本地模型开关并默认启用本地 bge-m3

- 新增 EmbeddingProvider:按 zsjz.embedding.enabled 在本地 ONNX 模型与
  Web 界面配置的在线 embedding 模型之间路由;本地不可用时不静默回退
- 模板向量索引、Agent 表结构检索、附件 RAG 统一接入该路由层
- 本地向量表按模型名隔离(rag_tpl_bge_m3_d1024 / chat_att_vec_*_bge_m3_d1024),
  避免与在线模型历史向量混写;在线模式沿用原表名
- EmbeddingService 增加声明维度、可用性、模型名与输出维度校验
- application.yaml 默认开启本地模型;补充开关路由单测与设计文档
cc 1 долоо хоног өмнө
parent
commit
654ea336bc

+ 2 - 1
AI_AGENT.md

@@ -173,7 +173,8 @@ tools/        SqlAnalysisTool、RagSchemaSearchTool、PythonAnalysisTool、
               WorkspaceInfoTool、GovernTableTool、各业务域 *AnalysisTool
 intent/       意图识别(IntentService / IntentMiddleware / IntentEnum)
 followup/     追问生成(FollowupService / FollowupMiddleware)
-rag/          RagSchemaService、EmbeddingModelFactory、RagProperties
+rag/          RagSchemaService、EmbeddingProvider(本地/在线向量模型路由)、
+              EmbeddingModelFactory、RagProperties
 python/       PythonExecutor(沙箱执行)
 sql/          SqlResultStore(结果集缓存,供前端分页;按登录用户归属)
 prompt/       PromptLoader、PromptHelper

+ 56 - 30
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java

@@ -1,13 +1,12 @@
 package com.zsjz.ai.module.agent.attachment;
 
 import com.zsjz.ai.common.utils.Json;
-import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
-import io.agentscope.core.message.TextBlock;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
-import java.time.Duration;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -17,13 +16,15 @@ import java.util.concurrent.Semaphore;
 /**
  * 附件大文档的 RAG 索引(路径 C):分块 → embedding → pgvector。
  *
- * <p>与 {@code RagSchemaService} 同一嵌入模型同一维度({@link EmbeddingModelFactory}),
- * 向量表按案件分表 {@code chat_att_vec_{caseId}_d{dims}},chunk 的 payload 强制带
- * {@code sessionId + attachmentId} —— 检索侧 WHERE 过滤会话(隔离要求)。
+ * <p>嵌入模型与表结构向量索引共用 {@link EmbeddingProvider}:{@code zsjz.embedding.enabled=true}
+ * 时走本地 ONNX 模型,false 时走 Web 界面配置的在线 embedding 模型。
+ * 向量表按案件分表 {@code chat_att_vec_{caseId}{tag}_d{dims}},本地模式带模型名标签,
+ * 与在线模型的历史向量物理隔离;chunk 的 payload 强制带 {@code sessionId + attachmentId}
+ * —— 检索侧 WHERE 过滤会话(隔离要求)。
  *
  * <p>同步索引(上传请求内完成):块数上限 {@code max-chunks}(默认 500),
- * 并发 {@code index-concurrency}(默认 8)打满远程 embedding API。
- * 超出上限的尾部内容截断并在注入块标注「仅索引前 N 字」—— 诚实告知,不静默丢。
+ * 并发 {@code index-concurrency}(默认 8)。本地 ONNX 推理是 CPU 密集型,并发过高不会更快,
+ * 必要时调小该配置。超出上限的尾部内容截断并在注入块标注「仅索引前 N 字」—— 诚实告知,不静默丢。
  *
  * @author cc
  * @since 2026/9/29
@@ -33,7 +34,7 @@ import java.util.concurrent.Semaphore;
 @RequiredArgsConstructor
 public class AttachmentDocIndexer {
 
-    private final EmbeddingModelFactory embeddingModelFactory;
+    private final EmbeddingProvider embeddingProvider;
 
     private final AttachmentVectorMapper vectorMapper;
 
@@ -78,15 +79,18 @@ public class AttachmentDocIndexer {
      * @throws IllegalStateException 未配置嵌入模型 / 建表失败(调用方标 FAILED)
      */
     public IndexResult index(String fullText, Long attachmentId, Long sessionId, Long caseId) {
-        EmbeddingModelFactory.EmbeddingSpec spec = embeddingModelFactory.resolveDefault();
-        if (spec == null) {
-            throw new IllegalStateException("未配置嵌入模型(model 表需存在 type='embedding' 的活跃记录),无法索引大文档");
+        if (!embeddingProvider.isAvailable()) {
+            throw new IllegalStateException(embeddingProvider.unavailableMessage() + ",无法索引大文档");
         }
-        String table = vectorTableName(caseId, spec.dimensions());
+        int dimensions = embeddingProvider.dimensions();
+        if (dimensions <= 0) {
+            throw new IllegalStateException("嵌入模型维度无效: " + dimensions + ",无法索引大文档");
+        }
+        String table = vectorTableName(caseId, embeddingProvider.tableTag(), dimensions);
         // 并发首建兜底:PG 的 CREATE TABLE IF NOT EXISTS 并发时会撞 pg_class 唯一索引报错
         // (多用户同时上传大文档是常态),失败后确认表已存在即视为成功
         try {
-            vectorMapper.createVectorTable(table, spec.dimensions());
+            vectorMapper.createVectorTable(table, dimensions);
         } catch (Exception e) {
             if (vectorMapper.tableExists(table) == 0) {
                 throw new IllegalStateException("附件向量表创建失败: " + e.getMessage());
@@ -131,9 +135,7 @@ public class AttachmentDocIndexer {
                         skipped.incrementAndGet();
                         return;
                     }
-                    double[] vec = spec.model()
-                            .embed(TextBlock.builder().text(content).build())
-                            .block(Duration.ofSeconds(60));
+                    double[] vec = embeddingProvider.embed(content);
                     Map<String, Object> payload = new LinkedHashMap<>();
                     payload.put("attachmentId", attachmentId);
                     payload.put("sessionId", sessionId);
@@ -181,13 +183,20 @@ public class AttachmentDocIndexer {
      * 按会话检索 top-k 片段(cosine,强制会话过滤)。
      */
     public List<String> search(String question, Long attachmentId, Long sessionId, Long caseId) {
-        EmbeddingModelFactory.EmbeddingSpec spec = embeddingModelFactory.resolveDefault();
-        if (spec == null) {
+        if (!embeddingProvider.isAvailable()) {
+            return List.of();
+        }
+        int dimensions = embeddingProvider.dimensions();
+        if (dimensions <= 0) {
+            return List.of();
+        }
+        double[] vec = embeddingProvider.embed(question);
+        String table = vectorTableName(caseId, embeddingProvider.tableTag(), dimensions);
+        if (vectorMapper.tableExists(table) == 0) {
+            // 本地/在线开关切换后,旧模型索引的附件在新表名下一片空白:按「无召回」降级,
+            // 不要把 relation does not exist 抛到每次提问的日志里
             return List.of();
         }
-        double[] vec = spec.model().embed(TextBlock.builder().text(question).build())
-                .block(Duration.ofSeconds(60));
-        String table = vectorTableName(caseId, spec.dimensions());
         List<Map<String, Object>> rows = vectorMapper.searchInSession(table, toVectorLiteral(vec),
                 String.valueOf(sessionId), props.getScoreThreshold(), props.getTopK());
         List<String> out = new ArrayList<>(rows.size());
@@ -244,20 +253,37 @@ public class AttachmentDocIndexer {
         return out;
     }
 
-    /** 撤销某附件的全部向量行(孤儿清理/删除附件时调用;维度未知时按两种常规维度各试一次) */
+    /**
+     * 撤销某附件的全部向量行(孤儿清理/删除附件时调用)。
+     *
+     * <p>本地/在线开关可能已切换,旧表名(无标签)与新表名(带标签)都要清,
+     * 常规维度 {1024,1536} 各试一次;表不存在是常态,静默忽略。
+     */
     public void deleteAttachmentVectors(Long attachmentId, Long caseId) {
+        String tag = embeddingProvider.tableTag();
         for (int dims : new int[]{1024, 1536}) {
-            try {
-                vectorMapper.deleteByAttachment(vectorTableName(caseId, dims), String.valueOf(attachmentId));
-            } catch (Exception ignore) {
-                // 表不存在是常态(该案件还没建过这个维度的向量表),静默
+            deleteQuietly(vectorTableName(caseId, tag, dims), attachmentId);
+            if (StringUtils.hasText(tag)) {
+                deleteQuietly(vectorTableName(caseId, "", dims), attachmentId);
             }
         }
     }
 
-    /** 向量表名(按案件分表,沿 rag_ws{id}_d{dims} 惯例) */
-    public static String vectorTableName(Long caseId, int dims) {
-        return "chat_att_vec_" + caseId + "_d" + dims;
+    private void deleteQuietly(String table, Long attachmentId) {
+        try {
+            vectorMapper.deleteByAttachment(table, String.valueOf(attachmentId));
+        } catch (Exception ignore) {
+            // 表不存在是常态(该案件还没建过这个维度的向量表),静默
+        }
+    }
+
+    /**
+     * 向量表名(按案件分表,沿 rag_ws{id}_d{dims} 惯例)。
+     *
+     * @param tag 模型标签:本地模式为 {@code _bge_m3},在线模式为空串(沿用旧表名)
+     */
+    public static String vectorTableName(Long caseId, String tag, int dims) {
+        return "chat_att_vec_" + caseId + (tag == null ? "" : tag) + "_d" + dims;
     }
 
     /** double[] → pgvector 字面量 */

+ 201 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/EmbeddingProvider.java

@@ -0,0 +1,201 @@
+package com.zsjz.ai.module.agent.rag;
+
+import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory.EmbeddingSpec;
+import com.zsjz.ai.module.embedding.service.EmbeddingService;
+import io.agentscope.core.message.TextBlock;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.Locale;
+
+/**
+ * 向量模型路由:按本地嵌入开关在「本地 ONNX 模型」与「在线 embedding 模型」之间二选一。
+ *
+ * <p>开关是 {@code zsjz.embedding.enabled}:
+ * <ul>
+ *   <li><b>true</b> → 全部向量能力(模板索引 / 表结构检索 / 附件 RAG)使用
+ *       {@link EmbeddingService}(bge-m3,进程内推理,无需网络);</li>
+ *   <li><b>false</b> → 回退到系统「模型管理」里配置的 {@code agent_model} 表
+ *       type='embedding' 的默认模型,由 {@link EmbeddingModelFactory} 构建 agentscope 实例。</li>
+ * </ul>
+ *
+ * <p><b>不做静默回退</b>:本地开关打开但模型目录缺失时,{@link #isAvailable()} 为 false,
+ * 调用方按「向量能力不可用」降级,而<b>不是</b>悄悄换成在线模型 —— 本地与在线是两套
+ * 不兼容的向量空间,混用只会让召回结果变成噪声。
+ *
+ * <p><b>向量表隔离</b>:{@link #tableTag()} 在本地模式下返回模型名后缀,调用方把它拼进
+ * pgvector 表名(如 {@code rag_tpl_bge_m3_d1024}),这样开关在本地/在线之间切换时,
+ * 两套向量天然落在不同表里,不会把在线模型的旧向量当成新模型的索引继续检索。
+ * 在线模式沿用原表名(无后缀),保持存量索引可用。
+ *
+ * <p>在线模型的 {@code agent_model} 查询结果缓存 30s:换模型 / 停用模型最多半分钟后生效,
+ * 不需要重启;本地模式不查库。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class EmbeddingProvider {
+
+    /** 在线 embedding 调用超时:单条最多等 60s,避免请求线程无限挂住 */
+    private static final Duration EMBED_TIMEOUT = Duration.ofSeconds(60);
+
+    /** 在线模型规格缓存时长(本地模式不涉及) */
+    private static final long SPEC_CACHE_MILLIS = 30_000L;
+
+    /** 向量表标签最大长度(PG 标识符 63 字符,留出表名前缀与维度的空间) */
+    private static final int TABLE_TAG_MAX_LEN = 32;
+
+    private final EmbeddingService localEmbeddingService;
+    private final EmbeddingModelFactory embeddingModelFactory;
+
+    /** 在线模型规格缓存(null 不缓存:没配模型时每次重查,配置模型后无需等缓存过期) */
+    private volatile CachedSpec cachedSpec;
+
+    /**
+     * 当前是否走本地模型(即 {@code zsjz.embedding.enabled=true})。
+     */
+    public boolean isLocal() {
+        return localEmbeddingService.isEnabled();
+    }
+
+    /**
+     * 当前模式下的向量能力是否可用。
+     *
+     * <p>本地:开关打开且模型目录存在(不触发 2.3GB 权重加载);
+     * 在线:{@code agent_model} 存在活跃的 type='embedding' 模型。
+     */
+    public boolean isAvailable() {
+        if (isLocal()) {
+            return localEmbeddingService.isAvailable();
+        }
+        return resolveSpec() != null;
+    }
+
+    /**
+     * 不可用原因(可直接展示给用户 / 写进接口报错)。
+     */
+    public String unavailableMessage() {
+        if (isLocal()) {
+            return "未配置嵌入模型(本地嵌入已开启但模型目录不存在,请检查 zsjz.embedding.model-path)";
+        }
+        return "未配置嵌入模型(agent_model 表需存在 type='embedding' 的活跃记录)";
+    }
+
+    /**
+     * 当前嵌入模型名:本地取模型目录名(如 bge-m3),在线取 model 表名称;不可用时返回 null。
+     */
+    public String modelName() {
+        if (isLocal()) {
+            return localEmbeddingService.getModelName();
+        }
+        EmbeddingSpec spec = resolveSpec();
+        return spec == null ? null : spec.modelName();
+    }
+
+    /**
+     * 向量维度:本地取配置声明维度(不加载权重),在线取模型配置的 dimensions;不可用时返回 0。
+     */
+    public int dimensions() {
+        if (isLocal()) {
+            return localEmbeddingService.getDeclaredDimension();
+        }
+        EmbeddingSpec spec = resolveSpec();
+        return spec == null ? 0 : spec.dimensions();
+    }
+
+    /**
+     * 向量表名后缀:本地模式按模型名隔离(如 {@code _bge_m3}),在线模式为空串(沿用原表名)。
+     */
+    public String tableTag() {
+        if (!isLocal()) {
+            return "";
+        }
+        return "_" + sanitizeTag(localEmbeddingService.getModelName());
+    }
+
+    /**
+     * 单文本嵌入。
+     *
+     * @param text 非空文本
+     * @return 与当前模式一致的向量(本地已 L2 归一化;在线由服务端决定)
+     * @throws IllegalStateException 当前模式不可用 / 在线模型返回空向量或维度不符
+     */
+    public double[] embed(String text) {
+        if (!StringUtils.hasText(text)) {
+            throw new IllegalArgumentException("嵌入文本不能为空");
+        }
+        if (isLocal()) {
+            return toDoubleArray(localEmbeddingService.embed(text));
+        }
+        EmbeddingSpec spec = resolveSpec();
+        if (spec == null) {
+            throw new IllegalStateException(unavailableMessage());
+        }
+        double[] vector = spec.model()
+                .embed(TextBlock.builder().text(text).build())
+                .block(EMBED_TIMEOUT);
+        if (vector == null || vector.length == 0) {
+            throw new IllegalStateException("在线嵌入模型返回空向量: " + spec.modelName());
+        }
+        if (vector.length != spec.dimensions()) {
+            throw new IllegalStateException("在线嵌入向量维度与模型配置不一致(配置 "
+                    + spec.dimensions() + ",实际 " + vector.length + "): " + spec.modelName());
+        }
+        return vector;
+    }
+
+    /**
+     * 解析默认在线嵌入模型(带 30s 缓存)。
+     *
+     * @return 规格;未配置 / 解析失败返回 null(不抛)
+     */
+    private EmbeddingSpec resolveSpec() {
+        CachedSpec cached = cachedSpec;
+        long now = System.currentTimeMillis();
+        if (cached != null && cached.expireAtMillis() > now) {
+            return cached.spec();
+        }
+        EmbeddingSpec spec;
+        try {
+            spec = embeddingModelFactory.resolveDefault();
+        } catch (Exception e) {
+            log.error("解析默认在线嵌入模型失败(按不可用处理): {}", e.getMessage());
+            spec = null;
+        }
+        cachedSpec = spec == null ? null : new CachedSpec(spec, now + SPEC_CACHE_MILLIS);
+        return spec;
+    }
+
+    private static double[] toDoubleArray(float[] vector) {
+        double[] out = new double[vector.length];
+        for (int i = 0; i < vector.length; i++) {
+            out[i] = vector[i];
+        }
+        return out;
+    }
+
+    /**
+     * 模型名 → PG 表名标签:只保留小写字母数字,其余折成下划线(bge-m3 → bge_m3)。
+     */
+    private static String sanitizeTag(String modelName) {
+        String raw = StringUtils.hasText(modelName) ? modelName : "local";
+        String tag = raw.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_");
+        tag = tag.replaceAll("^_+|_+$", "");
+        if (tag.isEmpty()) {
+            tag = "local";
+        }
+        return tag.length() > TABLE_TAG_MAX_LEN ? tag.substring(0, TABLE_TAG_MAX_LEN) : tag;
+    }
+
+    /**
+     * 在线模型规格缓存项。
+     */
+    private record CachedSpec(EmbeddingSpec spec, long expireAtMillis) {
+    }
+}

+ 6 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/RagSchemaService.java

@@ -10,6 +10,7 @@ import com.zsjz.ai.module.agent.vo.RagIndexStatusVO;
 import com.zsjz.ai.module.agent.vo.RagSearchResultVO;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
@@ -57,7 +58,8 @@ public class RagSchemaService {
     }
 
     /**
-     * RAG 能力是否可用:{@code etl.rag.enabled} 且索引侧已配置可用的嵌入模型。
+     * RAG 能力是否可用:{@code etl.rag.enabled} 且当前向量模型
+     * (本地 / 在线由 {@code zsjz.embedding.enabled} 决定)可用。
      */
     public boolean isAvailable() {
         return etlProperties.getRag().isEnabled() && vectorIndex.isAvailable();
@@ -135,8 +137,9 @@ public class RagSchemaService {
      */
     public List<RagSearchResultVO> search(String query, Integer topK) {
         if (!isAvailable()) {
-            throw new ServerException(409, "未配置嵌入模型(agent_model 表需存在 type='embedding' 的活跃记录),"
-                    + "无法进行表结构向量检索");
+            String reason = vectorIndex.unavailableMessage();
+            throw new ServerException(409,
+                    (StringUtils.hasText(reason) ? reason : "未配置嵌入模型") + ",无法进行表结构向量检索");
         }
         int limit = (topK != null && topK > 0) ? topK : etlProperties.getRag().getTopK();
         List<TableSchemaHit> hits = vectorIndex.searchTables(query, limit);

+ 108 - 124
ai-server/src/main/java/com/zsjz/ai/module/aiclean/index/AiCleanTemplateVectorIndex.java

@@ -13,19 +13,15 @@ import com.zsjz.ai.module.aiclean.entity.AiCleanTemplate;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanFieldMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper;
 import com.zsjz.ai.module.agent.mapper.RagVectorMapper;
-import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
-import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory.EmbeddingSpec;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
-import io.agentscope.core.embedding.EmbeddingModel;
-import io.agentscope.core.message.TextBlock;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.boot.context.event.ApplicationReadyEvent;
 import org.springframework.context.event.EventListener;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 
-import java.time.Duration;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -72,8 +68,8 @@ public class AiCleanTemplateVectorIndex {
     /** AI 模板库文档类型 */
     public static final String KIND_AI = "AI";
 
-    /** 向量表名前缀;完整表名 = rag_tpl_d{dims}(按维度隔离,换模型后自然重建) */
-    static final String VECTOR_TABLE_PREFIX = "rag_tpl_d";
+    /** 向量表名前缀;完整表名 = rag_tpl{tag}_d{dims}(按维度/模型隔离,换模型后自然重建) */
+    static final String VECTOR_TABLE_PREFIX = "rag_tpl";
 
     /**
      * 全量重建时「连续失败」熔断阈值。
@@ -89,22 +85,8 @@ public class AiCleanTemplateVectorIndex {
     /** 文档 id 前缀:AI 模板 a:{ai_clean_template.id} */
     private static final String DOC_ID_AI_PREFIX = "a:";
 
-    /**
-     * 嵌入调用超时:与 {@code RagSchemaService} 同口径,单条嵌入最多等 60s。
-     * 超时按「这条文档没写成功」处理,绝不让异步线程或请求线程无限挂住。
-     */
-    private static final Duration EMBED_TIMEOUT = Duration.ofSeconds(60);
-
-    /**
-     * EmbeddingSpec 解析缓存时长。
-     *
-     * <p>{@code resolveDefault()} 每次都要查 {@code agent_model} 表并构建 provider 实例;召回发生在每次 AI 识别上,
-     * 不缓存就是白查一次库。30s 足够短:换模型 / 停用模型最多半分钟后生效,不需要重启。
-     */
-    private static final long SPEC_CACHE_MILLIS = 30_000L;
-
     private final AiCleanVectorProperties properties;
-    private final EmbeddingModelFactory embeddingModelFactory;
+    private final EmbeddingProvider embeddingProvider;
     private final RagVectorMapper ragVectorMapper;
     private final TableInfoMapper tableInfoMapper;
     private final TableFieldMapper tableFieldMapper;
@@ -131,18 +113,15 @@ public class AiCleanTemplateVectorIndex {
      */
     private final Set<String> ensuredVectorTables = ConcurrentHashMap.newKeySet();
 
-    /** 嵌入模型规格缓存(含 null 不缓存:没配模型时每次重查,配置模型后无需等缓存过期) */
-    private volatile CachedSpec cachedSpec;
-
     public AiCleanTemplateVectorIndex(AiCleanVectorProperties properties,
-                                      EmbeddingModelFactory embeddingModelFactory,
+                                      EmbeddingProvider embeddingProvider,
                                       RagVectorMapper ragVectorMapper,
                                       TableInfoMapper tableInfoMapper,
                                       TableFieldMapper tableFieldMapper,
                                       AiCleanTemplateMapper aiCleanTemplateMapper,
                                       AiCleanFieldMapper aiCleanFieldMapper) {
         this.properties = properties;
-        this.embeddingModelFactory = embeddingModelFactory;
+        this.embeddingProvider = embeddingProvider;
         this.ragVectorMapper = ragVectorMapper;
         this.tableInfoMapper = tableInfoMapper;
         this.tableFieldMapper = tableFieldMapper;
@@ -151,22 +130,29 @@ public class AiCleanTemplateVectorIndex {
     }
 
     /**
-     * 向量索引是否可用:开关打开且配置了 type='embedding' 的活跃模型。
+     * 向量索引是否可用:开关打开且当前向量模型(本地 / 在线由 zsjz.embedding.enabled 决定)可用。
      */
     public boolean isAvailable() {
         if (!properties.isEnabled()) {
             return false;
         }
         try {
-            return resolveSpec() != null;
+            return embeddingProvider.isAvailable();
         } catch (Exception e) {
-            // 解析模型这一步理论上已 fail-open(EmbeddingModelFactory 内部吞异常返回 null),
+            // 路由层理论上已 fail-open(在线解析内部吞异常返回 null),
             // 这里再兜一层是因为 mock / 未来实现都可能抛,召回链路上不允许它冒泡
             log.error("判断 AI 模板向量索引可用性失败(按不可用处理): {}", e.getMessage());
             return false;
         }
     }
 
+    /**
+     * 当前向量模型不可用的原因(供接口层给出可操作提示)。
+     */
+    public String unavailableMessage() {
+        return embeddingProvider.unavailableMessage();
+    }
+
     /**
      * 增量重建一份既有模板的向量(异步,模板新增/更新后调用)。
      */
@@ -238,14 +224,15 @@ public class AiCleanTemplateVectorIndex {
         }
         try {
             if (!isAvailable()) {
-                log.info("启动自举跳过模板向量索引:未启用或未配置 type='embedding' 的活跃模型");
+                log.info("启动自举跳过模板向量索引:{}", embeddingProvider.unavailableMessage());
                 return;
             }
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                log.info("启动自举跳过模板向量索引:嵌入模型维度无效");
                 return;
             }
-            String vectorTable = vectorTableName(spec.dimensions());
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             boolean empty = !vectorTableExists(vectorTable)
                     || ragVectorMapper.countVectorDocs(vectorTable) == 0;
             if (!empty) {
@@ -265,19 +252,22 @@ public class AiCleanTemplateVectorIndex {
      * <p>先构建完整文档清单再动表:读库失败时旧的向量表还在,索引不会因为一次失败被清空。
      *
      * @return 实际写入的文档数(嵌入失败 / 维度不符的文档被跳过,不计入)
-     * @throws ServerException 开关关闭、未配置嵌入模型或建表 / 写库失败时
+     * @throws ServerException 开关关闭、向量模型不可用或建表 / 写库失败时
      */
     public int rebuildNow() {
         if (!properties.isEnabled()) {
             throw new ServerException(400, "AI 模板向量索引未启用(zsjz.ai-clean.vector.enabled=false)");
         }
-        EmbeddingSpec spec = resolveSpec();
-        if (spec == null) {
-            throw new ServerException(400, "未配置嵌入模型(agent_model 表需存在 type='embedding' 的活跃记录)");
+        if (!embeddingProvider.isAvailable()) {
+            throw new ServerException(400, embeddingProvider.unavailableMessage());
+        }
+        int dimensions = embeddingProvider.dimensions();
+        if (dimensions <= 0) {
+            throw new ServerException(500, "嵌入模型维度无效: " + dimensions);
         }
         synchronized (rebuildLock) {
             try {
-                String vectorTable = vectorTableName(spec.dimensions());
+                String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
                 List<VectorDoc> docs = loadAllDocuments();
                 int total = docs.size();
                 int max = properties.getMaxDocumentsPerRebuild();
@@ -287,25 +277,25 @@ public class AiCleanTemplateVectorIndex {
                     docs = docs.subList(0, max);
                 }
                 ragVectorMapper.dropVectorTable(vectorTable);
-                ragVectorMapper.createVectorTableIfNotExists(vectorTable, spec.dimensions());
+                ragVectorMapper.createVectorTableIfNotExists(vectorTable, dimensions);
                 ensuredVectorTables.add(vectorTable);
                 int written = 0;
                 // 连续失败熔断:embedding 连续失败到阈值就中止本次重建(返回已写入数),
-                // 通常是模型 / Key 不可用,继续逐条重试只会刷屏且拖长重建时间
+                // 通常是模型不可用,继续逐条重试只会刷屏且拖长重建时间
                 int consecutiveFailures = 0;
                 for (VectorDoc doc : docs) {
-                    if (writeDoc(vectorTable, spec, doc)) {
+                    if (writeDoc(vectorTable, dimensions, doc)) {
                         written++;
                         consecutiveFailures = 0;
                     } else if (++consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
                         log.error("AI 模板向量全量重建连续 {} 次写入失败,已中止(已写入 {} 条)。"
-                                        + "请检查 agent_model 中 type='embedding' 模型的 Key / 端点,或 pgvector 是否可用",
+                                        + "请检查嵌入模型(本地模型文件 / 在线模型 Key 与端点)或 pgvector 是否可用",
                                 consecutiveFailures, written);
                         break;
                     }
                 }
                 log.info("AI 模板向量全量重建完成: table={}, dims={}, docs={}/{}(截断前 {})",
-                        vectorTable, spec.dimensions(), written, docs.size(), total);
+                        vectorTable, dimensions, written, docs.size(), total);
                 return written;
             } catch (ServerException e) {
                 throw e;
@@ -329,20 +319,23 @@ public class AiCleanTemplateVectorIndex {
             return List.of();
         }
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
+            if (!embeddingProvider.isAvailable()) {
                 return List.of();
             }
             int limit = topK > 0 ? topK : properties.getTopK();
             if (limit <= 0) {
                 return List.of();
             }
-            String vectorTable = vectorTableName(spec.dimensions());
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                return List.of();
+            }
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             if (!vectorTableExists(vectorTable)) {
                 // 还没索引过(刚部署 / 从没 rebuild):直接按无召回处理,别让调用方等一次空查询异常
                 return List.of();
             }
-            double[] queryVector = embed(spec, queryText);
+            double[] queryVector = embed(queryText, dimensions);
             if (queryVector == null) {
                 return List.of();
             }
@@ -389,20 +382,23 @@ public class AiCleanTemplateVectorIndex {
             return List.of();
         }
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
+            if (!embeddingProvider.isAvailable()) {
                 return List.of();
             }
             int limit = topK > 0 ? topK : properties.getTopK();
             if (limit <= 0) {
                 return List.of();
             }
-            String vectorTable = vectorTableName(spec.dimensions());
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                return List.of();
+            }
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             if (!vectorTableExists(vectorTable)) {
                 // 还没建过索引(新部署 / 从没 rebuild):按无结果处理,别让调用方等一次空查询异常
                 return List.of();
             }
-            double[] queryVector = embed(spec, queryText);
+            double[] queryVector = embed(queryText, dimensions);
             if (queryVector == null) {
                 return List.of();
             }
@@ -435,11 +431,14 @@ public class AiCleanTemplateVectorIndex {
      */
     public IndexStatus status() {
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
+            if (!embeddingProvider.isAvailable()) {
                 return new IndexStatus(false, null, null, 0, 0L);
             }
-            String vectorTable = vectorTableName(spec.dimensions());
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                return new IndexStatus(false, null, embeddingProvider.modelName(), 0, 0L);
+            }
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             long docCount = 0L;
             try {
                 if (vectorTableExists(vectorTable)) {
@@ -448,8 +447,8 @@ public class AiCleanTemplateVectorIndex {
             } catch (Exception e) {
                 log.debug("读取模板向量表行数失败(状态按 0 处理): {}", e.getMessage());
             }
-            return new IndexStatus(properties.isEnabled(), vectorTable, spec.modelName(),
-                    spec.dimensions(), docCount);
+            return new IndexStatus(properties.isEnabled(), vectorTable, embeddingProvider.modelName(),
+                    dimensions, docCount);
         } catch (Exception e) {
             log.error("读取 AI 模板向量索引状态失败: {}", e.getMessage());
             return new IndexStatus(false, null, null, 0, 0L);
@@ -502,24 +501,28 @@ public class AiCleanTemplateVectorIndex {
      */
     int indexTableNow(Integer tableId) {
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
-                log.debug("未配置嵌入模型,跳过既有模板向量索引: tableId={}", tableId);
+            if (!embeddingProvider.isAvailable()) {
+                log.debug("嵌入模型不可用,跳过既有模板向量索引: tableId={}", tableId);
                 return 0;
             }
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                return 0;
+            }
+            String docId = DOC_ID_TABLE_PREFIX + tableId;
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             TableInfo table = tableInfoMapper.selectById(tableId);
             if (table == null) {
-                removeDoc(spec, DOC_ID_TABLE_PREFIX + tableId);
+                removeDoc(vectorTable, docId);
                 return 0;
             }
             List<TableField> fields = loadTableFields(table);
             if (fields.isEmpty()) {
-                removeDoc(spec, DOC_ID_TABLE_PREFIX + tableId);
+                removeDoc(vectorTable, docId);
                 return 0;
             }
-            String vectorTable = vectorTableName(spec.dimensions());
-            ensureVectorTable(vectorTable, spec.dimensions());
-            return writeDoc(vectorTable, spec, buildTableDoc(table, fields)) ? 1 : 0;
+            ensureVectorTable(vectorTable, dimensions);
+            return writeDoc(vectorTable, dimensions, buildTableDoc(table, fields)) ? 1 : 0;
         } catch (Exception e) {
             log.error("增量索引既有模板失败(已降级,不影响清洗主流程): tableId={}, error={}",
                     tableId, e.getMessage());
@@ -537,14 +540,19 @@ public class AiCleanTemplateVectorIndex {
      */
     int indexAiTemplateNow(Long aiTemplateId) {
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
-                log.debug("未配置嵌入模型,跳过 AI 模板向量索引: aiTemplateId={}", aiTemplateId);
+            if (!embeddingProvider.isAvailable()) {
+                log.debug("嵌入模型不可用,跳过 AI 模板向量索引: aiTemplateId={}", aiTemplateId);
                 return 0;
             }
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
+                return 0;
+            }
+            String docId = DOC_ID_AI_PREFIX + aiTemplateId;
+            String vectorTable = vectorTableName(embeddingProvider.tableTag(), dimensions);
             AiCleanTemplate template = aiCleanTemplateMapper.selectById(aiTemplateId);
             if (template == null) {
-                removeDoc(spec, DOC_ID_AI_PREFIX + aiTemplateId);
+                removeDoc(vectorTable, docId);
                 return 0;
             }
             List<AiCleanField> fields = aiCleanFieldMapper.selectList(
@@ -554,12 +562,11 @@ public class AiCleanTemplateVectorIndex {
                             .orderByAsc(AiCleanField::getId));
             if (fields == null || fields.isEmpty()) {
                 // 无字段的模板既不能匹配也不能被选表,按删除处理(与全量重建口径一致)
-                removeDoc(spec, DOC_ID_AI_PREFIX + aiTemplateId);
+                removeDoc(vectorTable, docId);
                 return 0;
             }
-            String vectorTable = vectorTableName(spec.dimensions());
-            ensureVectorTable(vectorTable, spec.dimensions());
-            return writeDoc(vectorTable, spec, buildAiDoc(template, fields)) ? 1 : 0;
+            ensureVectorTable(vectorTable, dimensions);
+            return writeDoc(vectorTable, dimensions, buildAiDoc(template, fields)) ? 1 : 0;
         } catch (Exception e) {
             log.error("增量索引 AI 模板失败(已降级,不影响清洗主流程): aiTemplateId={}, error={}",
                     aiTemplateId, e.getMessage());
@@ -572,18 +579,20 @@ public class AiCleanTemplateVectorIndex {
      */
     private void removeDoc(String docId) {
         try {
-            EmbeddingSpec spec = resolveSpec();
-            if (spec == null) {
+            if (!embeddingProvider.isAvailable()) {
+                return;
+            }
+            int dimensions = embeddingProvider.dimensions();
+            if (dimensions <= 0) {
                 return;
             }
-            removeDoc(spec, docId);
+            removeDoc(vectorTableName(embeddingProvider.tableTag(), dimensions), docId);
         } catch (Exception e) {
             log.error("删除模板向量失败(已降级): docId={}, error={}", docId, e.getMessage());
         }
     }
 
-    private void removeDoc(EmbeddingSpec spec, String docId) {
-        String vectorTable = vectorTableName(spec.dimensions());
+    private void removeDoc(String vectorTable, String docId) {
         if (!vectorTableExists(vectorTable)) {
             return;
         }
@@ -961,53 +970,32 @@ public class AiCleanTemplateVectorIndex {
     // ==================== 嵌入与向量存取 ====================
 
     /**
-     * 解析默认嵌入模型(带 30s 缓存)。
-     *
-     * @return 规格;未配置 / 解析失败返回 null(不抛)
+     * 嵌入单段文本并校验维度;维度不符 / 调用失败都返回 null(由调用方按「跳过该文档」处理)。
      */
-    private EmbeddingSpec resolveSpec() {
-        CachedSpec cached = cachedSpec;
-        long now = System.currentTimeMillis();
-        if (cached != null && cached.expireAtMillis() > now) {
-            return cached.spec();
-        }
-        EmbeddingSpec spec;
+    private double[] embed(String content, int dimensions) {
         try {
-            spec = embeddingModelFactory.resolveDefault();
+            double[] vector = embeddingProvider.embed(content);
+            if (vector == null || vector.length != dimensions) {
+                // 维度不符说明模型配置与实际输出对不上:这一行写进去会让整表向量检索崩掉
+                // (pgvector 的 vector(n) 列强校验维度),所以宁可跳过并告警
+                log.info("嵌入向量维度不一致,跳过该模板文档(期望 {},实际 {}): model={}",
+                        dimensions, vector == null ? 0 : vector.length, embeddingProvider.modelName());
+                return null;
+            }
+            return vector;
         } catch (Exception e) {
-            log.error("解析默认嵌入模型失败(按不可用处理): {}", e.getMessage());
-            spec = null;
-        }
-        cachedSpec = spec == null ? null : new CachedSpec(spec, now + SPEC_CACHE_MILLIS);
-        return spec;
-    }
-
-    /**
-     * 嵌入单段文本并校验维度;维度不符 / 调用失败都返回 null(由调用方按「跳过该文档」处理)。
-     */
-    private double[] embed(EmbeddingSpec spec, String content) {
-        EmbeddingModel model = spec.model();
-        if (model == null) {
-            log.info("嵌入模型实例为空,跳过本次模板向量计算: model={}", spec.modelName());
-            return null;
-        }
-        double[] vector = model.embed(TextBlock.builder().text(content).build()).block(EMBED_TIMEOUT);
-        if (vector == null || vector.length != spec.dimensions()) {
-            // 维度不符说明 model.config.dimensions 与实际模型对不上:这一行写进去会让整表向量检索崩掉
-            // (pgvector 的 vector(n) 列强校验维度),所以宁可跳过并告警
-            log.info("嵌入向量维度不一致,跳过该模板文档(期望 {},实际 {}): model={}",
-                    spec.dimensions(), vector == null ? 0 : vector.length, spec.modelName());
+            log.error("向量计算失败(跳过该模板文档): model={}, error={}",
+                    embeddingProvider.modelName(), e.getMessage());
             return null;
         }
-        return vector;
     }
 
     /**
      * 写入一篇文档;失败只告警不抛(单条失败不该拖垮整批增量/重建)。
      */
-    private boolean writeDoc(String vectorTable, EmbeddingSpec spec, VectorDoc doc) {
+    private boolean writeDoc(String vectorTable, int dimensions, VectorDoc doc) {
         try {
-            double[] vector = embed(spec, doc.content());
+            double[] vector = embed(doc.content(), dimensions);
             if (vector == null) {
                 return false;
             }
@@ -1044,13 +1032,15 @@ public class AiCleanTemplateVectorIndex {
     }
 
     /**
-     * 向量表名:rag_tpl_d{dims}。
+     * 向量表名:rag_tpl{tag}_d{dims}。
      *
-     * <p>维度进表名:换嵌入模型且维度变化时自然落到新表,不会出现「旧表 vector(1024) 塞 1536 维」的写失败;
+     * <p>维度进表名:换嵌入模型且维度变化时自然落到新表,不会出现「旧表 vector(1024) 塞 1536 维」的写失败。
+     * 本地模型额外带模型名标签(如 {@code rag_tpl_bge_m3_d1024}),与在线模型的历史向量物理隔离 ——
+     * 本地/在线开关切换后不会把另一套向量空间的结果当成当前索引继续检索。
      * 旧表按设计留待人工清理(见设计文档 §7)。
      */
-    static String vectorTableName(int dimensions) {
-        return VECTOR_TABLE_PREFIX + dimensions;
+    static String vectorTableName(String tag, int dimensions) {
+        return VECTOR_TABLE_PREFIX + (tag == null ? "" : tag) + "_d" + dimensions;
     }
 
     /**
@@ -1088,12 +1078,6 @@ public class AiCleanTemplateVectorIndex {
         }
     }
 
-    /**
-     * 嵌入模型规格缓存项。
-     */
-    private record CachedSpec(EmbeddingSpec spec, long expireAtMillis) {
-    }
-
     /**
      * 一条召回命中。
      *

+ 16 - 1
ai-server/src/main/java/com/zsjz/ai/module/embedding/config/EmbeddingProperties.java

@@ -17,7 +17,14 @@ import org.springframework.stereotype.Component;
 @ConfigurationProperties(prefix = "zsjz.embedding")
 public class EmbeddingProperties {
 
-    /** 是否启用本地嵌入模块(关闭后 EmbeddingService 调用直接报错) */
+    /**
+     * 向量模型开关:
+     * <ul>
+     *   <li>true → 全部向量能力(模板索引 / 表结构检索 / 附件 RAG)使用本地 ONNX 模型;</li>
+     *   <li>false → 回退到系统「模型管理」里配置的在线 type='embedding' 模型。</li>
+     * </ul>
+     * 本地开关打开但模型目录缺失时不会静默回退在线模型(避免两套向量空间混写)。
+     */
     private boolean enabled = true;
 
     /**
@@ -26,6 +33,14 @@ public class EmbeddingProperties {
      */
     private String modelPath = "bge-m3";
 
+    /**
+     * 模型声明输出维度(bge-m3 = 1024)。
+     *
+     * <p>只用于建向量表 / 状态展示,避免「查维度」就把 2.3GB 权重加载进内存;
+     * 首次推理时会校验实际维度,不一致直接报错而不是写坏 pgvector 表。
+     */
+    private int dimensions = 1024;
+
     /** 单文本最大 token 数(超出截断;bge-m3 上限 8192) */
     private int maxSeqLen = 8192;
 

+ 2 - 2
ai-server/src/main/java/com/zsjz/ai/module/embedding/controller/EmbeddingDebugController.java

@@ -16,8 +16,8 @@ import java.util.ArrayList;
 import java.util.List;
 
 /**
- * 嵌入调试控制器:仅供人工验证本地模型是否正常(维度/归一化/语义区分度),
- * 不承担业务功能,可随业务接入完成后移除
+ * 嵌入调试控制器:人工验证本地模型是否正常(维度/归一化/语义区分度)。
+ * 只作用于本地 ONNX 模型,不参与业务路由(业务侧统一走 {@code EmbeddingProvider})。
  */
 @Slf4j
 @RestController

+ 59 - 4
ai-server/src/main/java/com/zsjz/ai/module/embedding/service/EmbeddingService.java

@@ -17,6 +17,10 @@ import java.util.List;
 /**
  * 本地嵌入服务:业务侧统一入口(独立模块,不依赖 agentscope / model 表 / RAG 链路)。
  *
+ * <p>本类是<b>本地模型</b>的实现;需要「本地 / 在线」切换的业务调用方统一走
+ * {@code com.zsjz.ai.module.agent.rag.EmbeddingProvider}:
+ * {@code zsjz.embedding.enabled=true} 时用本类,false 时回退 Web 界面配置的在线 embedding 模型。
+ *
  * <p>基于本地 ONNX 模型(默认 bge-m3,1024 维稠密向量,已 L2 归一化)在进程内完成嵌入推理,
  * 无需外部 API 与网络。向量已归一化,余弦相似度可直接用内积近似({@link #cosineSimilarity}
  * 仍按完整余弦公式计算,兼容外部未归一化向量)。
@@ -44,11 +48,13 @@ import java.util.List;
 public class EmbeddingService implements AutoCloseable {
 
     private final EmbeddingProperties properties;
+    private final Path modelDir;
     private final OnnxEmbeddingEngine engine;
 
     public EmbeddingService(EmbeddingProperties properties) {
         this.properties = properties;
-        this.engine = new OnnxEmbeddingEngine(resolveModelDir(properties.getModelPath()),
+        this.modelDir = resolveModelDir(properties.getModelPath());
+        this.engine = new OnnxEmbeddingEngine(modelDir,
                 properties.getMaxSeqLen(), properties.getIntraOpThreads(), properties.getOptLevel());
     }
 
@@ -73,7 +79,7 @@ public class EmbeddingService implements AutoCloseable {
      */
     public float[] embed(String text) {
         checkEnabled();
-        return engine.embed(List.of(text)).get(0);
+        return checkDimension(engine.embed(List.of(text)).get(0));
     }
 
     /**
@@ -91,7 +97,9 @@ public class EmbeddingService implements AutoCloseable {
         int batchSize = Math.max(1, properties.getBatchSize());
         for (int from = 0; from < texts.size(); from += batchSize) {
             List<String> chunk = texts.subList(from, Math.min(from + batchSize, texts.size()));
-            result.addAll(engine.embed(chunk));
+            for (float[] vector : engine.embed(chunk)) {
+                result.add(checkDimension(vector));
+            }
         }
         return result;
     }
@@ -124,6 +132,39 @@ public class EmbeddingService implements AutoCloseable {
         return engine.getDimension();
     }
 
+    /**
+     * 声明维度(配置值,不触发模型加载):建向量表、状态展示等只需要维度的场景使用。
+     */
+    public int getDeclaredDimension() {
+        return properties.getDimensions();
+    }
+
+    /**
+     * 本地嵌入开关(zsjz.embedding.enabled)。
+     *
+     * <p>true = 全部向量能力使用本地模型;false = 回退在线 embedding 模型。调用方据此路由。
+     */
+    public boolean isEnabled() {
+        return properties.isEnabled();
+    }
+
+    /**
+     * 本地模型是否可用:开关打开且模型目录存在。
+     *
+     * <p>不触发权重加载,因此可以在状态查询 / 启动自举里安全调用。
+     */
+    public boolean isAvailable() {
+        return properties.isEnabled() && Files.isDirectory(modelDir);
+    }
+
+    /**
+     * 本地模型名(取模型目录名,如 bge-m3),用于状态展示与向量表命名隔离。
+     */
+    public String getModelName() {
+        Path fileName = modelDir.getFileName();
+        return fileName == null ? "local" : fileName.toString();
+    }
+
     /**
      * 模型是否已加载就绪(懒加载模式下首次调用前为 false)
      */
@@ -137,7 +178,8 @@ public class EmbeddingService implements AutoCloseable {
     public synchronized void warmUp() {
         checkEnabled();
         engine.ensureLoaded();
-        engine.embed(List.of("预热"));
+        // 走 embed() 以复用声明维度校验:配置与实际模型不符在预热阶段就暴露
+        embed("预热");
     }
 
     /**
@@ -161,6 +203,19 @@ public class EmbeddingService implements AutoCloseable {
         }
     }
 
+    /**
+     * 实际输出维度必须与声明维度一致:pgvector 的 vector(n) 列强校验维度,
+     * 写错维度会让整张向量表不可用,不如在推理出口就明确报错。
+     */
+    private float[] checkDimension(float[] vector) {
+        int expected = properties.getDimensions();
+        if (expected > 0 && vector.length != expected) {
+            throw new EmbeddingException("本地嵌入模型输出维度与配置不一致(zsjz.embedding.dimensions="
+                    + expected + ",实际 " + vector.length + "),请修正配置后重建向量索引");
+        }
+        return vector;
+    }
+
     /**
      * 模型目录解析:绝对路径直接使用;相对路径优先按工作目录(user.dir)解析,
      * 其次按进程当前目录解析,均不存在时给出明确报错

+ 17 - 9
ai-server/src/main/resources/application.yaml

@@ -80,18 +80,23 @@ zsjz:
     # 单个文件识别的模型调用超时(秒)
     timeout-seconds: 120
 
-  # 本地嵌入模型(进程内 ONNX 推理,独立于 agent_model 表的远程嵌入配置)。
-  # 默认 bge-m3:输出 1024 维稠密向量(CLS 池化 + L2 归一化),见 EmbeddingService。
+  # 向量模型(进程内 ONNX 本地模型 / Web 界面配置的在线 embedding 模型)。
+  # 开关语义(zsjz.embedding.enabled):
+  #   true  → 全部向量能力(模板索引 / 表结构检索 / 附件 RAG)使用本地 bge-m3;
+  #   false → 回退使用系统「模型管理」里配置的 agent_model type='embedding' 活跃模型。
+  # 本地模型默认 bge-m3:输出 1024 维稠密向量(CLS 池化 + L2 归一化),见 EmbeddingService。
   # 注意:权重常驻堆外内存约 2.5~3GB,首次加载 20~60s(懒加载,不阻塞启动);
-  # 机器内存紧张时可关闭本模块(enabled=false),业务侧调用会得到明确报错。
+  # 本地开关打开但模型目录缺失时不会静默回退在线模型,业务侧按「向量能力不可用」降级。
   # Java 25 运行时 ONNX Runtime 加载 native 库会打 "restricted method" 警告(不影响运行),
   # 可通过 JVM 参数 --enable-native-access=ALL-UNNAMED 消除。
   embedding:
-    # 是否启用本地嵌入模块
-    enabled: false
+    # 向量模型开关:true = 本地模型;false = 在线模型
+    enabled: true
     # 模型目录:含 tokenizer.json 与 onnx/model.onnx(+ model.onnx_data 外部权重)。
     # 相对路径优先按工作目录(user.dir)解析,其次按进程当前目录解析
     model-path: bge-m3
+    # 模型声明输出维度(bge-m3 = 1024):建向量表/状态展示用,不触发权重加载
+    dimensions: 1024
     # 单文本最大 token 数(超出截断;bge-m3 上限 8192,长文本 CPU 推理耗时显著上升)
     max-seq-len: 8192
     # 批量推理每批文本条数(批内按最长文本 padding)
@@ -114,9 +119,11 @@ zsjz:
     stale-minutes: 30
     # 模板元数据向量索引(module/aiclean/index):
     # 只读两套模板元数据(table_info/table_field + ai_clean_template/ai_clean_field,不扫描 DuckDB),
-    # 写进独立向量表 rag_tpl_d{dims}。同一份索引服务两个用途:
+    # 写进独立向量表 rag_tpl{tag}_d{dims}(本地模型带模型名标签,如 rag_tpl_bge_m3_d1024)。
+    # 同一份索引服务两个用途:
     #   ① Agent search_table_schema 选表(生成 SQL)  ② AI 模板匹配的向量召回(只补候选,不判档)。
-    # 前提:agent_model 表存在 type='embedding' 的活跃模型 + PG 已装 pgvector;未满足时全链路 fail-open。
+    # 前提:向量模型可用(本地开关打开且模型目录存在,或 agent_model 表存在 type='embedding' 活跃模型)
+    # + PG 已装 pgvector;未满足时全链路 fail-open。
     vector:
       # 总开关;关闭行为与改造前完全一致(无索引、无召回)
       enabled: true
@@ -134,7 +141,7 @@ zsjz:
   # 对话附件(输入框附件问答,module/agent/attachment)。
   # 三路:小内容内联进消息;大表格只给文件路径,Agent 用 execute_python 全量计算;
   # 大文档分块向量化(RAG)按问题检索片段注入 —— 大文件不截断不算错。
-  # 大文档 RAG 依赖 model 表里有活跃的 type=embedding 模型(与 RagSchemaService 同一前提)。
+  # 大文档 RAG 与表结构索引共用同一个向量模型(本地开关 / 在线 embedding 二选一)。
   # 附件按 userId/caseId/sessionId 三层隔离;24h 未发消息的孤儿附件自动清理。
   chat-attachment:
     # 文档大小上限(MB,Tika 解析对象)
@@ -154,7 +161,8 @@ zsjz:
 # Agent 表结构语义检索(RAG):向量索引的事实来源只有两套模板元数据
 # (table_info/table_field + ai_clean_template/ai_clean_field),不扫描案件 DuckDB。
 # 索引同时服务:① search_table_schema(Agent 生成 SQL 时选表)② AI 模板匹配召回。
-# 依赖 agent_model 表里有活跃的 type=embedding 模型 + PG pgvector;缺失时工具返回可读错误,不影响清洗。
+# 依赖向量模型可用(本地开关打开,或 agent_model 表有活跃 type=embedding 模型)+ PG pgvector;
+# 缺失时工具返回可读错误,不影响清洗。
 etl:
   rag:
     # 是否注册 search_table_schema 工具

+ 13 - 14
ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexerTest.java

@@ -1,17 +1,13 @@
 package com.zsjz.ai.module.agent.attachment;
 
-import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
-import com.zsjz.ai.module.agent.attachment.AttachmentVectorMapper;
-import io.agentscope.core.embedding.EmbeddingModel;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
-import reactor.core.publisher.Mono;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -23,17 +19,19 @@ import static org.mockito.Mockito.when;
 /**
  * AttachmentDocIndexer(S-4)索引总预算回归:
  * 嵌入端点整体变慢时,超预算的 chunk 跳过(truncated 置真)而不是把上传请求挂住几十分钟。
+ *
+ * <p>嵌入模型已统一走 {@link EmbeddingProvider}(本地 / 在线由开关路由),这里只桩路由结果。
  */
 class AttachmentDocIndexerTest {
 
-    private EmbeddingModelFactory factory;
+    private EmbeddingProvider embeddingProvider;
     private AttachmentVectorMapper vectorMapper;
     private ChatAttachmentProperties props;
     private AttachmentDocIndexer indexer;
 
     @BeforeEach
     void setUp() {
-        factory = mock(EmbeddingModelFactory.class);
+        embeddingProvider = mock(EmbeddingProvider.class);
         vectorMapper = mock(AttachmentVectorMapper.class);
         props = new ChatAttachmentProperties();
         props.setChunkSize(10);
@@ -41,12 +39,12 @@ class AttachmentDocIndexerTest {
         props.setMaxChunks(50);
         props.setIndexConcurrency(4);
         props.setIndexBudgetSeconds(600);
-        indexer = new AttachmentDocIndexer(factory, vectorMapper, props);
+        indexer = new AttachmentDocIndexer(embeddingProvider, vectorMapper, props);
 
-        EmbeddingModel embeddingModel = mock(EmbeddingModel.class);
-        when(embeddingModel.embed(any())).thenReturn(Mono.just(new double[]{0.5, 0.5}));
-        when(factory.resolveDefault())
-                .thenReturn(new EmbeddingModelFactory.EmbeddingSpec(1L, "test-embed", embeddingModel, 2));
+        when(embeddingProvider.isAvailable()).thenReturn(true);
+        when(embeddingProvider.dimensions()).thenReturn(2);
+        when(embeddingProvider.tableTag()).thenReturn("");
+        when(embeddingProvider.embed(anyString())).thenReturn(new double[]{0.5, 0.5});
         when(vectorMapper.tableExists(anyString())).thenReturn(1);
     }
 
@@ -71,9 +69,10 @@ class AttachmentDocIndexerTest {
     }
 
     @Test
-    @DisplayName("嵌入模型未配置:明确报错(维持既有语义)")
+    @DisplayName("嵌入模型不可用(本地模型缺失 / 未配置在线模型):明确报错,不建表")
     void noEmbeddingModelFails() {
-        when(factory.resolveDefault()).thenReturn(null);
+        when(embeddingProvider.isAvailable()).thenReturn(false);
+        when(embeddingProvider.unavailableMessage()).thenReturn("未配置嵌入模型");
         IllegalStateException ex = assertThrows(IllegalStateException.class,
                 () -> indexer.index("x".repeat(1000), 1L, 1L, 1L));
         assertTrue(ex.getMessage().contains("未配置嵌入模型"));

+ 115 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/rag/EmbeddingProviderTest.java

@@ -0,0 +1,115 @@
+package com.zsjz.ai.module.agent.rag;
+
+import com.zsjz.ai.module.embedding.service.EmbeddingService;
+import io.agentscope.core.embedding.EmbeddingModel;
+import io.agentscope.core.message.TextBlock;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link EmbeddingProvider} 的开关路由契约:{@code zsjz.embedding.enabled=true} 走本地 ONNX 模型,
+ * false 回退 Web 界面配置的在线 embedding 模型;本地不可用时<b>不静默回退</b>在线。
+ */
+class EmbeddingProviderTest {
+
+    private EmbeddingService localEmbeddingService;
+    private EmbeddingModelFactory embeddingModelFactory;
+    private EmbeddingProvider provider;
+
+    @BeforeEach
+    void setUp() {
+        localEmbeddingService = mock(EmbeddingService.class);
+        embeddingModelFactory = mock(EmbeddingModelFactory.class);
+        provider = new EmbeddingProvider(localEmbeddingService, embeddingModelFactory);
+    }
+
+    @Test
+    @DisplayName("开关开启:使用本地模型,向量表带模型名标签,不查在线模型")
+    void localEnabledUsesLocalModel() {
+        when(localEmbeddingService.isEnabled()).thenReturn(true);
+        when(localEmbeddingService.isAvailable()).thenReturn(true);
+        when(localEmbeddingService.getDeclaredDimension()).thenReturn(1024);
+        when(localEmbeddingService.getModelName()).thenReturn("bge-m3");
+        when(localEmbeddingService.embed("交易日期")).thenReturn(new float[]{0.1f, 0.2f});
+
+        assertTrue(provider.isLocal());
+        assertTrue(provider.isAvailable());
+        assertEquals(1024, provider.dimensions());
+        assertEquals("bge-m3", provider.modelName());
+        assertEquals("_bge_m3", provider.tableTag(), "本地向量表必须带模型标签,避免与在线向量混写");
+        assertArrayEquals(new double[]{0.1, 0.2}, provider.embed("交易日期"), 1e-6);
+        verifyNoInteractions(embeddingModelFactory);
+    }
+
+    @Test
+    @DisplayName("开关关闭:回退在线模型,沿用原表名,不调用本地推理")
+    void localDisabledUsesOnlineModel() {
+        when(localEmbeddingService.isEnabled()).thenReturn(false);
+        EmbeddingModel onlineModel = mock(EmbeddingModel.class);
+        when(onlineModel.embed(any(TextBlock.class))).thenReturn(Mono.just(new double[]{0.3, 0.4}));
+        when(embeddingModelFactory.resolveDefault())
+                .thenReturn(new EmbeddingModelFactory.EmbeddingSpec(1L, "qwen-embed", onlineModel, 2));
+
+        assertFalse(provider.isLocal());
+        assertTrue(provider.isAvailable());
+        assertEquals(2, provider.dimensions());
+        assertEquals("qwen-embed", provider.modelName());
+        assertEquals("", provider.tableTag(), "在线模式沿用原表名,保持存量索引可用");
+        assertArrayEquals(new double[]{0.3, 0.4}, provider.embed("交易日期"), 1e-9);
+        verify(localEmbeddingService, never()).embed(anyString());
+        verify(localEmbeddingService, never()).getDeclaredDimension();
+    }
+
+    @Test
+    @DisplayName("本地开关打开但模型缺失:按不可用降级,绝不静默回退在线模型")
+    void localEnabledButModelMissingDoesNotFallBack() {
+        when(localEmbeddingService.isEnabled()).thenReturn(true);
+        when(localEmbeddingService.isAvailable()).thenReturn(false);
+
+        assertTrue(provider.isLocal());
+        assertFalse(provider.isAvailable());
+        assertTrue(provider.unavailableMessage().contains("模型目录不存在"), provider.unavailableMessage());
+        verifyNoInteractions(embeddingModelFactory);
+    }
+
+    @Test
+    @DisplayName("在线模式未配置模型:不可用、维度 0、表名无标签")
+    void onlineModelMissingIsUnavailable() {
+        when(localEmbeddingService.isEnabled()).thenReturn(false);
+        when(embeddingModelFactory.resolveDefault()).thenReturn(null);
+
+        assertFalse(provider.isAvailable());
+        assertEquals(0, provider.dimensions());
+        assertNull(provider.modelName());
+        assertEquals("", provider.tableTag());
+        assertTrue(provider.unavailableMessage().contains("agent_model"), provider.unavailableMessage());
+    }
+
+    @Test
+    @DisplayName("在线模型输出维度与配置不一致:明确报错,不把错维向量写进 pgvector")
+    void onlineDimensionMismatchFails() {
+        when(localEmbeddingService.isEnabled()).thenReturn(false);
+        EmbeddingModel onlineModel = mock(EmbeddingModel.class);
+        when(onlineModel.embed(any(TextBlock.class))).thenReturn(Mono.just(new double[]{0.3, 0.4}));
+        when(embeddingModelFactory.resolveDefault())
+                .thenReturn(new EmbeddingModelFactory.EmbeddingSpec(1L, "qwen-embed", onlineModel, 4));
+
+        assertThrows(IllegalStateException.class, () -> provider.embed("交易日期"));
+    }
+}

+ 18 - 26
ai-server/src/test/java/com/zsjz/ai/module/aiclean/index/AiCleanTemplateVectorIndexTest.java

@@ -13,14 +13,12 @@ import com.zsjz.ai.module.aiclean.index.AiCleanTemplateVectorIndex.TableSchemaHi
 import com.zsjz.ai.module.aiclean.mapper.AiCleanFieldMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper;
 import com.zsjz.ai.module.agent.mapper.RagVectorMapper;
-import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
-import io.agentscope.core.embedding.EmbeddingModel;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
-import reactor.core.publisher.Mono;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -60,30 +58,28 @@ import static org.mockito.Mockito.when;
 class AiCleanTemplateVectorIndexTest {
 
     private AiCleanVectorProperties properties;
-    private EmbeddingModelFactory embeddingModelFactory;
+    private EmbeddingProvider embeddingProvider;
     private RagVectorMapper ragVectorMapper;
     private TableInfoMapper tableInfoMapper;
     private TableFieldMapper tableFieldMapper;
     private AiCleanTemplateMapper aiCleanTemplateMapper;
     private AiCleanFieldMapper aiCleanFieldMapper;
 
-    private EmbeddingModel embeddingModel;
     private AiCleanTemplateVectorIndex index;
 
     @BeforeEach
     void setUp() {
         properties = new AiCleanVectorProperties();
-        embeddingModelFactory = mock(EmbeddingModelFactory.class);
+        embeddingProvider = mock(EmbeddingProvider.class);
         ragVectorMapper = mock(RagVectorMapper.class);
         tableInfoMapper = mock(TableInfoMapper.class);
         tableFieldMapper = mock(TableFieldMapper.class);
         aiCleanTemplateMapper = mock(AiCleanTemplateMapper.class);
         aiCleanFieldMapper = mock(AiCleanFieldMapper.class);
-        index = new AiCleanTemplateVectorIndex(properties, embeddingModelFactory, ragVectorMapper,
+        index = new AiCleanTemplateVectorIndex(properties, embeddingProvider, ragVectorMapper,
                 tableInfoMapper, tableFieldMapper, aiCleanTemplateMapper, aiCleanFieldMapper);
 
-        embeddingModel = mock(EmbeddingModel.class);
-        when(embeddingModel.embed(any())).thenReturn(Mono.just(new double[]{0.1, 0.2}));
+        stubProvider(2);
     }
 
     // ==================== 1. 既有模板文档构建 ====================
@@ -189,7 +185,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("recall:嵌入查询文本 + pgvector 检索,命中行正确映射为 VectorHit(分数/id/kind)")
     void recallMapsHits() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
         when(ragVectorMapper.searchVector(anyString(), anyString(), anyDouble(), anyInt()))
                 .thenReturn(List.of(
@@ -218,7 +213,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("searchTables:命中映射成完整表结构(物理表名/表注释/物理列名/类型/注释/必填/分数)")
     void searchTablesMapsHits() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
         String tablePayload = "{\"kind\":\"TABLE\",\"templateId\":77,"
                 + "\"tableNameEn\":\"trans_record\",\"tableNameCn\":\"交易流水\",\"fields\":["
@@ -277,7 +271,7 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("searchTables 不可用:没配模型返回空且不查向量表(fail-open)")
     void searchTablesFailOpenWithoutModel() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(null);
+        when(embeddingProvider.isAvailable()).thenReturn(false);
 
         List<TableSchemaHit> hits = assertDoesNotThrow(() -> index.searchTables("转账金额", 5));
 
@@ -289,7 +283,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("searchTables 索引还没建:直接返回空,不查也不建表")
     void searchTablesReturnsEmptyWhenVectorTableMissing() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(0);
 
         List<TableSchemaHit> hits = index.searchTables("转账金额", 5);
@@ -303,7 +296,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("增量索引 AI 模板:DRAFT 模板同样写入(物理表真实存在,Agent 选表必须看得见)")
     void indexesAiTemplateRegardlessOfStatus() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         AiCleanTemplate draft = aiTemplate(9001L, "DRAFT");
         when(aiCleanTemplateMapper.selectById(9001L)).thenReturn(draft);
         when(aiCleanFieldMapper.selectList(any())).thenReturn(List.of(
@@ -321,7 +313,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("增量索引 AI 模板:无字段的模板按删除处理(旧向量是脏数据),已删除的模板同样删")
     void removesAiTemplateWhenNoFieldsOrDeleted() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
         when(aiCleanTemplateMapper.selectById(9001L)).thenReturn(aiTemplate(9001L, "ONLINE"));
         when(aiCleanFieldMapper.selectList(any())).thenReturn(List.of());
@@ -337,7 +328,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("全量重建:AI 模板全部状态都进文档(ONLINE + DRAFT),无字段的跳过")
     void rebuildIndexesAllAiTemplateStatuses() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(tableInfoMapper.selectList(any())).thenReturn(List.of());
         when(tableFieldMapper.selectList(any())).thenReturn(List.of());
         AiCleanTemplate online = aiTemplate(9001L, "ONLINE");
@@ -364,7 +354,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("全量重建连续失败熔断:写库连续失败 5 次即中止,返回已写入数(0),不刷屏重试到底")
     void rebuildStopsAfterConsecutiveFailures() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         List<TableInfo> tables = new ArrayList<>();
         List<TableField> fields = new ArrayList<>();
         for (int i = 1; i <= 20; i++) {
@@ -390,7 +379,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("启动自举:索引表不存在时后台全量重建(异步,dropVectorTable 被调用)")
     void startupRebuildsWhenVectorTableMissing() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(0);
         when(tableInfoMapper.selectList(any())).thenReturn(List.of());
         when(tableFieldMapper.selectList(any())).thenReturn(List.of());
@@ -406,7 +394,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("启动自举:索引表已有数据则不重建")
     void startupSkipsRebuildWhenIndexNotEmpty() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
         when(ragVectorMapper.countVectorDocs("rag_tpl_d2")).thenReturn(42L);
 
@@ -430,7 +417,7 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("模型未配置:isAvailable=false、recall 返回空且不抛、不查向量表")
     void noEmbeddingModelIsFailOpen() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(null);
+        when(embeddingProvider.isAvailable()).thenReturn(false);
 
         assertFalse(index.isAvailable());
         List<AiCleanTemplateVectorIndex.VectorHit> hits =
@@ -445,7 +432,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("DELETE 事件:按 docId 删除对应模板向量(异步,TABLE 与 AI_TEMPLATE 各一路)")
     void deleteEventRemovesDoc() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
 
         index.onTemplateMetaChanged(new TemplateMetaChangedEvent(
@@ -476,7 +462,7 @@ class AiCleanTemplateVectorIndexTest {
     @DisplayName("嵌入维度与配置不一致:跳过写入、不抛(pgvector vector(n) 列会拒绝错维向量)")
     void dimensionMismatchSkipsWrite() {
         // 配置 4 维,模型实际回 2 维(setUp 里的桩)
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(4));
+        stubProvider(4);
         TableInfo table = tableInfo(88);
         when(tableInfoMapper.selectById(88)).thenReturn(table);
         when(tableFieldMapper.selectList(any())).thenReturn(
@@ -495,7 +481,6 @@ class AiCleanTemplateVectorIndexTest {
     @DisplayName("全量重建:受 maxDocumentsPerRebuild 限制,超限截断并 warn(返回实际写入数)")
     void rebuildTruncatesAtLimit() {
         properties.setMaxDocumentsPerRebuild(1);
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
 
         TableInfo first = tableInfo(1);
         TableInfo second = tableInfo(2);
@@ -517,7 +502,6 @@ class AiCleanTemplateVectorIndexTest {
     @Test
     @DisplayName("status:返回可用性 / 表名 / 模型名 / 维度 / 文档数")
     void statusReportsIndexState() {
-        when(embeddingModelFactory.resolveDefault()).thenReturn(spec(2));
         when(ragVectorMapper.tableExists("rag_tpl_d2")).thenReturn(1);
         when(ragVectorMapper.countVectorDocs("rag_tpl_d2")).thenReturn(42L);
 
@@ -532,8 +516,16 @@ class AiCleanTemplateVectorIndexTest {
 
     // ==================== 测试辅助 ====================
 
-    private EmbeddingModelFactory.EmbeddingSpec spec(int dimensions) {
-        return new EmbeddingModelFactory.EmbeddingSpec(1L, "test-embed", embeddingModel, dimensions);
+    /**
+     * 向量模型桩:在线模式(tableTag 为空)2 维,embed 固定返回 2 维向量。
+     * 维度不一致的用例再单独改成 4 维即可复现「配置与实际不符」。
+     */
+    private void stubProvider(int dimensions) {
+        when(embeddingProvider.isAvailable()).thenReturn(true);
+        when(embeddingProvider.dimensions()).thenReturn(dimensions);
+        when(embeddingProvider.modelName()).thenReturn("test-embed");
+        when(embeddingProvider.tableTag()).thenReturn("");
+        when(embeddingProvider.embed(anyString())).thenReturn(new double[]{0.1, 0.2});
     }
 
     private static TableInfo tableInfo(int id) {

+ 20 - 9
docs/design/ai-template-vector-index.md

@@ -1,6 +1,6 @@
 # 模板元数据向量索引(统一服务:Agent 查表 + AI 模板匹配)
 
-> 版本:v2.0(2026-10-19,纠偏后)
+> 版本:v2.1(2026-10-22,向量模型本地/在线开关)
 > 模块:`ai-server/src/main/java/com/zsjz/ai/module/aiclean/index/`
 > 关键约束:**只读模板库元数据,不扫描 DuckDB**。
 
@@ -29,7 +29,7 @@ AI 模板匹配保持同一口径)。
 
 ```
 table_info + table_field ──┐
-                           ├─> AiCleanTemplateVectorIndex ──> pgvector 表 rag_tpl_d{dims}
+                           ├─> AiCleanTemplateVectorIndex ──> pgvector 表 rag_tpl{tag}_d{dims}
 ai_clean_template + field ─┘        ↑ 增量事件 / 全量重建 / 启动自举      │
                                                                         ├─> searchTables(query) → Agent 查表(生成 SQL)
                                                                         └─> recall(query)      → AI 模板匹配召回
@@ -37,7 +37,9 @@ ai_clean_template + field ─┘        ↑ 增量事件 / 全量重建 / 启动
 
 ### 2.1 存储
 
-- 表名 `rag_tpl_d{dims}`(按 embedding 维度隔离;换模型维度变了自然落新表)。
+- 表名 `rag_tpl{tag}_d{dims}`(按 embedding 维度 + 模型隔离;换模型自然落新表)。
+  - `zsjz.embedding.enabled=true`(本地模型):`tag=_bge_m3`,如 `rag_tpl_bge_m3_d1024`;
+  - `false`(在线模型):`tag` 为空串,沿用 `rag_tpl_d{dims}`,保持存量索引可用。
 - 列:`doc_id` 主键 / `content` 原文 / `payload jsonb` / `embedding vector(dims)`。
 - `doc_id`:既有模板 `t:{table_info.id}`,AI 模板 `a:{ai_clean_template.id}`。
 - `payload`:`{kind, templateId|aiTemplateId, tableNameEn, tableNameCn, mainId, headers[], category,
@@ -71,8 +73,10 @@ ai_clean_template + field ─┘        ↑ 增量事件 / 全量重建 / 启动
 
 ### 2.5 失败语义(fail-open)
 
-- 没配 `agent_model` 中 `type='embedding'` 活跃模型 / 开关关闭:`isAvailable()=false`,`recall`/`searchTables` 返回空。
-- 全量重建连续嵌入失败 ≥5 次自动中止(通常是模型/Key 不可用),避免刷屏;返回已写入数。
+- **向量模型统一由 `EmbeddingProvider` 路由**:`zsjz.embedding.enabled=true` 走本地 ONNX bge-m3,
+  `false` 回退 `agent_model` 中 `type='embedding'` 的活跃模型。本地开关打开但模型目录缺失时**不静默回退**在线模型。
+- 模型不可用 / 开关关闭:`isAvailable()=false`,`recall`/`searchTables` 返回空。
+- 全量重建连续嵌入失败 ≥5 次自动中止(通常是模型不可用),避免刷屏;返回已写入数。
 - `search_table_schema` 在索引为空时返回 409 且提示先 rebuild;`list_tables` 不依赖向量索引,仍可用。
 - 事件发布/索引更新失败只 `log.warn`,绝不影响模板落库与清洗主流程。
 
@@ -80,6 +84,11 @@ ai_clean_template + field ─┘        ↑ 增量事件 / 全量重建 / 启动
 
 ```yaml
 zsjz:
+  embedding:
+    enabled: true                        # true = 本地 bge-m3;false = 在线 embedding 模型
+    model-path: bge-m3
+    dimensions: 1024                     # 本地模型声明维度(建表/状态用,不触发加载)
+
   ai-clean:
     vector:
       enabled: true
@@ -116,13 +125,15 @@ etl:
   连续失败熔断)、`WorkspaceSchemaCatalogTest`(两套元数据合并、物理列名、无字段跳过、异常上抛)、
   `RagSchemaServiceTest`(search/reindex 门面映射、索引空 409)、`SqlAnalysisToolTest`(list_tables 含 AI 表、兜底)、
   `AiTemplateMatcherTest`(召回只影响候选、异常降级)。
-- 真库(需 dev PG + pgvector + 有效 embedding 模型):调一次 `/aiClean/templateVector/rebuild`,
-  `select count(*) from rag_tpl_d1024` 应 = 「有字段的 table_info + 有字段的 ai_clean_template」数量;
+- 真库(需 dev PG + pgvector + 可用向量模型):调一次 `/aiClean/templateVector/rebuild`,
+  `select count(*) from rag_tpl_bge_m3_d1024`(本地模式)或 `rag_tpl_d1024`(在线模式)
+  应 = 「有字段的 table_info + 有字段的 ai_clean_template」数量;
   再新建/更新一份模板,行数应增量 +1/覆盖。
 
 ## 7. 已知边界 / 后续
 
 - 索引最终一致:异步构建,模板刚落库的极短窗口内可能检索不到(不影响指纹/打分腿)。
-- 换嵌入模型且维度变化后旧 `rag_tpl_d{旧dims}` 表需人工 drop。
+- 换嵌入模型(含本地/在线开关切换)会落到新表名;旧 `rag_tpl*` 表需人工 drop(不会自动删除数据)。
+  附件 RAG 同理:切换后旧模型索引的附件在新表名下无召回,按「无片段」降级,不会串用旧向量。
 - 向量召回只解决“召回”不解决“判对”;AI 模板匹配的出口硬门保持不变。
-- 若后续做「AI 模板相似合并去重 / 别名词典」,可直接复用这套 `rag_tpl_d*` 表,无需再建索引。
+- 若后续做「AI 模板相似合并去重 / 别名词典」,可直接复用这套 `rag_tpl*` 表,无需再建索引。