Răsfoiți Sursa

feat: 新增本地嵌入模块(DJL + ONNX Runtime + bge-m3)

- 独立模块 module/embedding,不依赖 agentscope/model 表/RAG 链路
- EmbeddingService 业务门面:embed/embedBatch/cosineSimilarity/预热
- OnnxEmbeddingEngine:DJL 分词 + ORT 推理 + CLS 池化 + L2 归一化,
  输入输出名动态探测(兼容 token_embeddings 导出命名)
- 配置走 zsjz.embedding.*(application.yaml),引擎懒加载单例
- 调试接口 POST /embedding/debug;手动质量测试 -Dembedding.manual=true
- 已验证:1024 维/归一化正确,相关句对 0.85 vs 无关 0.41
cc 1 săptămână în urmă
părinte
comite
d2845e3f97

+ 15 - 0
ai-server/pom.xml

@@ -26,6 +26,8 @@
         <testcontainers.version>1.21.4</testcontainers.version>
         <agentscope.version>2.0.1</agentscope.version>
         <pgvector.version>0.1.6</pgvector.version>
+        <djl.version>0.38.0</djl.version>
+        <onnxruntime.version>1.30.0</onnxruntime.version>
     </properties>
     <dependencyManagement>
         <dependencies>
@@ -142,6 +144,19 @@
             <version>${pgvector.version}</version>
         </dependency>
 
+        <!-- 本地嵌入模型推理:DJL HuggingFace 分词器(Rust 原生,加载 tokenizer.json,
+             传递引入 ai.djl:api)+ ONNX Runtime CPU(各平台 native 库内置 jar) -->
+        <dependency>
+            <groupId>ai.djl.huggingface</groupId>
+            <artifactId>tokenizers</artifactId>
+            <version>${djl.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.microsoft.onnxruntime</groupId>
+            <artifactId>onnxruntime</artifactId>
+            <version>${onnxruntime.version}</version>
+        </dependency>
+
         <dependency>
             <groupId>com.zaxxer</groupId>
             <artifactId>HikariCP</artifactId>

+ 43 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/config/EmbeddingProperties.java

@@ -0,0 +1,43 @@
+package com.zsjz.ai.module.embedding.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 本地嵌入模型配置。
+ *
+ * <p>对应 application.yaml 中的 {@code zsjz.embedding} 配置节。
+ * 模型默认为 bge-m3(ONNX 导出版,输出 1024 维稠密向量,CLS 池化 + L2 归一化),
+ * 目录结构约定:{@code model-path} 指向的目录下应包含 {@code tokenizer.json}
+ * 与 {@code onnx/model.onnx}(+ {@code model.onnx_data} 外部权重)。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "zsjz.embedding")
+public class EmbeddingProperties {
+
+    /** 是否启用本地嵌入模块(关闭后 EmbeddingService 调用直接报错) */
+    private boolean enabled = true;
+
+    /**
+     * 模型目录:含 tokenizer.json 与 onnx/model.onnx。
+     * 相对路径优先按 工作目录(user.dir)解析,其次按进程当前目录解析。
+     */
+    private String modelPath = "bge-m3";
+
+    /** 单文本最大 token 数(超出截断;bge-m3 上限 8192) */
+    private int maxSeqLen = 8192;
+
+    /** 批量推理时每批文本条数(批内按最长文本 padding) */
+    private int batchSize = 8;
+
+    /** ORT intra-op 并行线程数;0 表示交给 ONNX Runtime 默认策略 */
+    private int intraOpThreads = 0;
+
+    /** ONNX 图优化级别:NO_OPT / BASIC_OPT / EXTENDED_OPT / ALL_OPT(ALL 首次加载更慢) */
+    private String optLevel = "BASIC_OPT";
+
+    /** 是否在启动时后台预热加载模型;默认懒加载(首次调用时才载入约 2.3GB 权重) */
+    private boolean eagerLoad = false;
+}

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

@@ -0,0 +1,62 @@
+package com.zsjz.ai.module.embedding.controller;
+
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.embedding.dto.EmbeddingDebugDTO;
+import com.zsjz.ai.module.embedding.service.EmbeddingService;
+import com.zsjz.ai.module.embedding.vo.EmbeddingDebugVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 嵌入调试控制器:仅供人工验证本地模型是否正常(维度/归一化/语义区分度),
+ * 不承担业务功能,可随业务接入完成后移除
+ */
+@Slf4j
+@RestController
+@RequestMapping("/embedding")
+@RequiredArgsConstructor
+public class EmbeddingDebugController {
+
+    private final EmbeddingService embeddingService;
+
+    /**
+     * 批量嵌入文本并返回两两余弦相似度
+     */
+    @PostMapping("/debug")
+    public Result<EmbeddingDebugVO> debug(@Valid @RequestBody EmbeddingDebugDTO dto) {
+        long start = System.currentTimeMillis();
+        List<float[]> vectors = embeddingService.embedBatch(dto.getTexts());
+        long millis = System.currentTimeMillis() - start;
+
+        EmbeddingDebugVO vo = new EmbeddingDebugVO();
+        vo.setDimension(embeddingService.getDimension());
+        vo.setEmbedMillis(millis);
+        vo.setVectors(vectors);
+        vo.setSimilarity(similarityMatrix(vectors));
+        return Result.succeed(vo);
+    }
+
+    private List<List<Double>> similarityMatrix(List<float[]> vectors) {
+        List<List<Double>> matrix = new ArrayList<>(vectors.size());
+        for (float[] row : vectors) {
+            List<Double> line = new ArrayList<>(vectors.size());
+            for (float[] col : vectors) {
+                line.add(round4(embeddingService.cosineSimilarity(row, col)));
+            }
+            matrix.add(line);
+        }
+        return matrix;
+    }
+
+    private static double round4(double value) {
+        return Math.round(value * 10000) / 10000.0;
+    }
+}

+ 15 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/core/EmbeddingException.java

@@ -0,0 +1,15 @@
+package com.zsjz.ai.module.embedding.core;
+
+/**
+ * 本地嵌入模块异常:模型加载失败、推理出错、向量维度异常等
+ */
+public class EmbeddingException extends RuntimeException {
+
+    public EmbeddingException(String message) {
+        super(message);
+    }
+
+    public EmbeddingException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 299 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/core/OnnxEmbeddingEngine.java

@@ -0,0 +1,299 @@
+package com.zsjz.ai.module.embedding.core;
+
+import ai.djl.huggingface.tokenizers.Encoding;
+import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
+import ai.onnxruntime.OnnxTensor;
+import ai.onnxruntime.OrtEnvironment;
+import ai.onnxruntime.OrtSession;
+import ai.onnxruntime.OrtSession.SessionOptions;
+import lombok.extern.slf4j.Slf4j;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * ONNX 本地嵌入推理引擎(面向 bge-m3 导出物,兼容同类 XLM-RoBERTa/BERT 结构模型)。
+ *
+ * <p>加载约定:{@code modelDir} 下含 {@code tokenizer.json} 与 {@code onnx/model.onnx}
+ * (外部权重 {@code model.onnx_data} 必须与主文件同目录;不存在 onnx 子目录时回退
+ * {@code modelDir/model.onnx})。
+ *
+ * <p>推理流程:DJL HuggingFaceTokenizer 原生批内 padding/截断 → ORT 会话推理 →
+ * 取 {@code last_hidden_state[:, 0]}(CLS 位置)→ L2 归一化(bge-m3 官方 dense 用法)。
+ * 输入/输出名在加载时从会话动态探测,兼容不同导出命名(token_type_ids / position_ids
+ * 按需补零或按 RoBERTa 规则生成)。
+ *
+ * <p>实例由 {@code EmbeddingService} 持有为单例:权重常驻堆外内存(约 2.5~3GB),
+ * 懒加载由服务层控制,引擎本身保证 {@link #ensureLoaded()} 幂等线程安全。
+ * ORT {@code session.run} 线程安全;分词调用内部串行化以规避原生句柄并发风险。
+ */
+@Slf4j
+public class OnnxEmbeddingEngine implements AutoCloseable {
+
+    /** 模型可能接受、引擎能够供给的输入名(其余输入出现即报错,便于定位异常导出物) */
+    private static final Set<String> SUPPORTED_INPUTS = Set.of(
+            "input_ids", "attention_mask", "token_type_ids", "position_ids");
+
+    private final OrtEnvironment ortEnv = OrtEnvironment.getEnvironment();
+    private final Path modelDir;
+    private final int maxSeqLen;
+    private final int intraOpThreads;
+    private final String optLevel;
+
+    private volatile HuggingFaceTokenizer tokenizer;
+    private volatile OrtSession session;
+    private volatile boolean closed;
+
+    /** 会话实际声明的输入名集合 */
+    private volatile Set<String> inputNames;
+    /** 池化输出名(优先 last_hidden_state,否则首个输出) */
+    private volatile String embeddingOutputName;
+    /** 输出向量维度,首次推理后填充 */
+    private volatile int dimension = -1;
+
+    public OnnxEmbeddingEngine(Path modelDir, int maxSeqLen, int intraOpThreads, String optLevel) {
+        this.modelDir = modelDir;
+        this.maxSeqLen = maxSeqLen;
+        this.intraOpThreads = intraOpThreads;
+        this.optLevel = optLevel;
+    }
+
+    /**
+     * 懒加载分词器与 ORT 会话(幂等,首次调用承担 2.3GB 权重的加载耗时)
+     */
+    public synchronized void ensureLoaded() {
+        if (session != null || closed) {
+            return;
+        }
+        if (!Files.isDirectory(modelDir)) {
+            throw new EmbeddingException("嵌入模型目录不存在: " + modelDir.toAbsolutePath()
+                    + "(请检查 zsjz.embedding.model-path 配置,目录下应含 tokenizer.json 与 onnx/model.onnx)");
+        }
+        long start = System.currentTimeMillis();
+        try {
+            // 显式传 modelMaxLength:目录方式加载不读 tokenizer_config.json,DJL 缺省会按 512 截断
+            tokenizer = HuggingFaceTokenizer.newInstance(modelDir,
+                    Map.of("modelMaxLength", String.valueOf(maxSeqLen)));
+        } catch (Exception e) {
+            throw new EmbeddingException("加载分词器失败: " + modelDir.resolve("tokenizer.json"), e);
+        }
+        Path modelFile = modelDir.resolve("onnx").resolve("model.onnx");
+        if (!Files.isRegularFile(modelFile)) {
+            modelFile = modelDir.resolve("model.onnx");
+        }
+        if (!Files.isRegularFile(modelFile)) {
+            throw new EmbeddingException("模型目录下未找到 ONNX 模型文件(期望 onnx/model.onnx 或 model.onnx): "
+                    + modelDir.toAbsolutePath());
+        }
+        try (SessionOptions options = new SessionOptions()) {
+            options.setOptimizationLevel(parseOptLevel(optLevel));
+            if (intraOpThreads > 0) {
+                options.setIntraOpNumThreads(intraOpThreads);
+            }
+            // 从文件路径创建会话:ONNX Runtime 依据模型文件所在目录解析外部权重 model.onnx_data
+            session = ortEnv.createSession(modelFile.toAbsolutePath().toString(), options);
+        } catch (EmbeddingException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new EmbeddingException("创建 ONNX 会话失败: " + modelFile, e);
+        }
+
+        inputNames = session.getInputNames();
+        Set<String> unsupported = new HashSet<>(inputNames);
+        unsupported.removeAll(SUPPORTED_INPUTS);
+        if (!unsupported.isEmpty()) {
+            throw new EmbeddingException("模型存在引擎无法供给的输入: " + unsupported
+                    + "(模型输入: " + inputNames + ")");
+        }
+        if (!inputNames.contains("input_ids")) {
+            throw new EmbeddingException("模型缺少必需输入 input_ids(实际输入: " + inputNames + ")");
+        }
+        embeddingOutputName = session.getOutputNames().stream()
+                .filter("last_hidden_state"::equals)
+                .findFirst()
+                .orElse(session.getOutputNames().iterator().next());
+        log.info("本地嵌入模型加载完成: modelDir={}, 耗时 {} ms, inputs={}, output={}, maxSeqLen={}",
+                modelDir.toAbsolutePath(), System.currentTimeMillis() - start, inputNames,
+                embeddingOutputName, maxSeqLen);
+    }
+
+    public boolean isReady() {
+        return session != null && !closed;
+    }
+
+    /**
+     * 输出向量维度(首次调用会触发模型加载与一次最小推理)
+     */
+    public int getDimension() {
+        ensureLoaded();
+        if (dimension <= 0) {
+            embed(List.of("维度探测"));
+        }
+        return dimension;
+    }
+
+    /**
+     * 批量嵌入一批文本(一次会话调用,批内由分词器原生按最长 padding)
+     *
+     * <p>调用方负责按配置的 batch-size 分批;本方法不做条数限制。
+     *
+     * @return 与输入顺序一致的 L2 归一化向量列表
+     */
+    public List<float[]> embed(List<String> texts) {
+        ensureLoaded();
+        if (texts == null || texts.isEmpty()) {
+            return List.of();
+        }
+        for (int i = 0; i < texts.size(); i++) {
+            if (texts.get(i) == null || texts.get(i).isBlank()) {
+                throw new EmbeddingException("嵌入文本不能为空(第 " + i + " 条)");
+            }
+        }
+
+        Encoding[] encodings;
+        synchronized (tokenizer) {
+            encodings = tokenizer.batchEncode(texts);
+        }
+        int batch = encodings.length;
+        int seqLen = encodings[0].getIds().length;
+        long[][] inputIds = new long[batch][];
+        long[][] attentionMask = new long[batch][];
+        for (int i = 0; i < batch; i++) {
+            inputIds[i] = encodings[i].getIds();
+            attentionMask[i] = encodings[i].getAttentionMask();
+            if (encodings[i].getIds().length != seqLen) {
+                throw new EmbeddingException("分词器批内 padding 结果长度不一致(期望 " + seqLen
+                        + ",第 " + i + " 条 " + encodings[i].getIds().length + ")");
+            }
+        }
+
+        Map<String, OnnxTensor> inputs = new LinkedHashMap<>();
+        try {
+            inputs.put("input_ids", OnnxTensor.createTensor(ortEnv, inputIds));
+            inputs.put("attention_mask", OnnxTensor.createTensor(ortEnv, attentionMask));
+            if (inputNames.contains("token_type_ids")) {
+                inputs.put("token_type_ids", OnnxTensor.createTensor(ortEnv, new long[batch][seqLen]));
+            }
+            if (inputNames.contains("position_ids")) {
+                inputs.put("position_ids", OnnxTensor.createTensor(ortEnv, robertaPositionIds(attentionMask)));
+            }
+        } catch (Exception e) {
+            inputs.values().forEach(OnnxTensor::close);
+            throw new EmbeddingException("构建模型输入张量失败", e);
+        }
+
+        long start = System.currentTimeMillis();
+        try (OrtSession.Result result = session.run(inputs)) {
+            Object value = result.get(embeddingOutputName)
+                    .orElseThrow(() -> new EmbeddingException("模型输出缺失: " + embeddingOutputName))
+                    .getValue();
+            List<float[]> vectors = poolAndNormalize(value, batch);
+            log.debug("本地嵌入推理完成: batch={}, seqLen={}, 耗时 {} ms",
+                    batch, seqLen, System.currentTimeMillis() - start);
+            return vectors;
+        } catch (EmbeddingException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new EmbeddingException("ONNX 推理失败: " + e.getMessage(), e);
+        } finally {
+            inputs.values().forEach(OnnxTensor::close);
+        }
+    }
+
+    /**
+     * CLS 池化 + L2 归一化:兼容 [batch, seq, hidden] 与 [batch, hidden] 两种输出形态
+     */
+    private List<float[]> poolAndNormalize(Object value, int batch) {
+        if (value instanceof float[][][] hidden) {
+            if (hidden.length != batch || hidden[0].length == 0) {
+                throw new EmbeddingException("模型输出 batch 维度异常(期望 " + batch
+                        + ",实际 " + hidden.length + ")");
+            }
+            List<float[]> vectors = new ArrayList<>(batch);
+            for (int i = 0; i < batch; i++) {
+                vectors.add(l2Normalize(hidden[i][0]));
+            }
+            dimension = vectors.get(0).length;
+            return vectors;
+        }
+        if (value instanceof float[][] pooled) {
+            if (pooled.length != batch) {
+                throw new EmbeddingException("模型输出 batch 维度异常(期望 " + batch
+                        + ",实际 " + pooled.length + ")");
+            }
+            List<float[]> vectors = new ArrayList<>(batch);
+            for (int i = 0; i < batch; i++) {
+                vectors.add(l2Normalize(pooled[i]));
+            }
+            dimension = vectors.get(0).length;
+            return vectors;
+        }
+        throw new EmbeddingException("不支持的模型输出形态: "
+                + (value == null ? "null" : value.getClass().getName()));
+    }
+
+    private static float[] l2Normalize(float[] vector) {
+        double sum = 0;
+        for (float v : vector) {
+            sum += (double) v * v;
+        }
+        float norm = (float) Math.sqrt(sum);
+        if (norm > 0) {
+            for (int i = 0; i < vector.length; i++) {
+                vector[i] /= norm;
+            }
+        }
+        return vector;
+    }
+
+    /**
+     * RoBERTa/XLM-R 位置编码:真实 token 位置 = attention_mask 前缀和 + padding_idx + 1,
+     * 右填充布局下与 HF create_position_ids_from_input_ids 结果一致(填充位被注意力掩码屏蔽)
+     */
+    private static long[][] robertaPositionIds(long[][] attentionMask) {
+        long[][] positions = new long[attentionMask.length][];
+        for (int i = 0; i < attentionMask.length; i++) {
+            long[] row = new long[attentionMask[i].length];
+            long cumsum = 0;
+            for (int j = 0; j < row.length; j++) {
+                cumsum += attentionMask[i][j];
+                row[j] = cumsum + 1;
+            }
+            positions[i] = row;
+        }
+        return positions;
+    }
+
+    private static SessionOptions.OptLevel parseOptLevel(String optLevel) {
+        try {
+            return SessionOptions.OptLevel.valueOf(optLevel.trim().toUpperCase(Locale.ROOT));
+        } catch (Exception e) {
+            log.warn("无效的图优化级别 {},回退 BASIC_OPT", optLevel);
+            return SessionOptions.OptLevel.BASIC_OPT;
+        }
+    }
+
+    @Override
+    public synchronized void close() {
+        closed = true;
+        if (session != null) {
+            try {
+                session.close();
+            } catch (Exception e) {
+                log.warn("关闭 ONNX 会话失败: {}", e.getMessage());
+            }
+            session = null;
+        }
+        if (tokenizer != null) {
+            tokenizer.close();
+            tokenizer = null;
+        }
+    }
+}

+ 19 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/dto/EmbeddingDebugDTO.java

@@ -0,0 +1,19 @@
+package com.zsjz.ai.module.embedding.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 嵌入调试请求:批量嵌入文本并计算两两余弦相似度
+ */
+@Data
+public class EmbeddingDebugDTO {
+
+    /** 待嵌入文本(1~16 条) */
+    @NotEmpty(message = "texts 不能为空")
+    @Size(max = 16, message = "一次最多嵌入 16 条文本")
+    private List<String> texts;
+}

+ 189 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/service/EmbeddingService.java

@@ -0,0 +1,189 @@
+package com.zsjz.ai.module.embedding.service;
+
+import com.zsjz.ai.module.embedding.config.EmbeddingProperties;
+import com.zsjz.ai.module.embedding.core.EmbeddingException;
+import com.zsjz.ai.module.embedding.core.OnnxEmbeddingEngine;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 本地嵌入服务:业务侧统一入口(独立模块,不依赖 agentscope / model 表 / RAG 链路)。
+ *
+ * <p>基于本地 ONNX 模型(默认 bge-m3,1024 维稠密向量,已 L2 归一化)在进程内完成嵌入推理,
+ * 无需外部 API 与网络。向量已归一化,余弦相似度可直接用内积近似({@link #cosineSimilarity}
+ * 仍按完整余弦公式计算,兼容外部未归一化向量)。
+ *
+ * <p>业务使用示例:
+ * <pre>{@code
+ * @RequiredArgsConstructor
+ * public class XxxService {
+ *     private final EmbeddingService embeddingService;
+ *
+ *     public void demo() {
+ *         float[] vec = embeddingService.embed("转账给可疑账户");
+ *         List<float[]> batch = embeddingService.embedBatch(List.of("文本A", "文本B"));
+ *         double sim = embeddingService.cosineSimilarity(vec, batch.get(0));
+ *     }
+ * }
+ * }</pre>
+ *
+ * <p>配置见 {@code zsjz.embedding.*}({@link EmbeddingProperties});
+ * 引擎懒加载:首次调用时才载入约 2.3GB 权重(耗时 20~60s),可通过
+ * {@code zsjz.embedding.eager-load=true} 或 {@link #warmUpAsync()} 提前预热。
+ */
+@Slf4j
+@Service
+public class EmbeddingService implements AutoCloseable {
+
+    private final EmbeddingProperties properties;
+    private final OnnxEmbeddingEngine engine;
+
+    public EmbeddingService(EmbeddingProperties properties) {
+        this.properties = properties;
+        this.engine = new OnnxEmbeddingEngine(resolveModelDir(properties.getModelPath()),
+                properties.getMaxSeqLen(), properties.getIntraOpThreads(), properties.getOptLevel());
+    }
+
+    @PostConstruct
+    public void init() {
+        if (!properties.isEnabled()) {
+            log.info("本地嵌入模块未启用(zsjz.embedding.enabled=false)");
+            return;
+        }
+        if (properties.isEagerLoad()) {
+            log.info("eager-load 已开启,后台预热本地嵌入模型...");
+            warmUpAsync();
+        }
+    }
+
+    /**
+     * 单文本嵌入
+     *
+     * @param text 非空文本
+     * @return L2 归一化后的稠密向量(默认 1024 维)
+     * @throws EmbeddingException 文本为空、模块未启用或推理失败
+     */
+    public float[] embed(String text) {
+        checkEnabled();
+        return engine.embed(List.of(text)).get(0);
+    }
+
+    /**
+     * 批量嵌入(内部按 zsjz.embedding.batch-size 分批推理)
+     *
+     * @param texts 非空文本列表(列表本身可为空,返回空列表)
+     * @return 与输入顺序一致的向量列表
+     */
+    public List<float[]> embedBatch(List<String> texts) {
+        checkEnabled();
+        if (texts == null || texts.isEmpty()) {
+            return List.of();
+        }
+        List<float[]> result = new ArrayList<>(texts.size());
+        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));
+        }
+        return result;
+    }
+
+    /**
+     * 余弦相似度(向量已归一化时等价于点积)
+     */
+    public double cosineSimilarity(float[] a, float[] b) {
+        if (a == null || b == null || a.length == 0 || a.length != b.length) {
+            throw new EmbeddingException("计算余弦相似度失败:向量长度不一致或为空("
+                    + (a == null ? 0 : a.length) + " vs " + (b == null ? 0 : b.length) + ")");
+        }
+        double dot = 0;
+        double normA = 0;
+        double normB = 0;
+        for (int i = 0; i < a.length; i++) {
+            dot += (double) a[i] * b[i];
+            normA += (double) a[i] * a[i];
+            normB += (double) b[i] * b[i];
+        }
+        double denom = Math.sqrt(normA) * Math.sqrt(normB);
+        return denom == 0 ? 0 : dot / denom;
+    }
+
+    /**
+     * 输出向量维度(首次调用触发模型加载)
+     */
+    public int getDimension() {
+        checkEnabled();
+        return engine.getDimension();
+    }
+
+    /**
+     * 模型是否已加载就绪(懒加载模式下首次调用前为 false)
+     */
+    public boolean isReady() {
+        return properties.isEnabled() && engine.isReady();
+    }
+
+    /**
+     * 同步预热:立即加载模型并跑一次最小推理(阻塞直到就绪,幂等)
+     */
+    public synchronized void warmUp() {
+        checkEnabled();
+        engine.ensureLoaded();
+        engine.embed(List.of("预热"));
+    }
+
+    /**
+     * 异步预热:后台守护线程加载模型,不阻塞调用方
+     */
+    public void warmUpAsync() {
+        Thread.ofPlatform().daemon(true).name("embedding-warmup").start(() -> {
+            try {
+                long start = System.currentTimeMillis();
+                warmUp();
+                log.info("本地嵌入模型预热完成,耗时 {} ms", System.currentTimeMillis() - start);
+            } catch (Exception e) {
+                log.warn("本地嵌入模型预热失败: {}", e.getMessage());
+            }
+        });
+    }
+
+    private void checkEnabled() {
+        if (!properties.isEnabled()) {
+            throw new EmbeddingException("本地嵌入模块未启用(zsjz.embedding.enabled=false)");
+        }
+    }
+
+    /**
+     * 模型目录解析:绝对路径直接使用;相对路径优先按工作目录(user.dir)解析,
+     * 其次按进程当前目录解析,均不存在时给出明确报错
+     */
+    private static Path resolveModelDir(String modelPath) {
+        Path path = Path.of(modelPath);
+        if (path.isAbsolute()) {
+            return path;
+        }
+        Path byUserDir = Path.of(System.getProperty("user.dir"), modelPath);
+        if (Files.isDirectory(byUserDir)) {
+            return byUserDir;
+        }
+        if (StringUtils.hasText(modelPath) && Files.isDirectory(path)) {
+            return path;
+        }
+        // 未找到也先返回 user.dir 下的候选路径,由引擎加载时给出包含绝对路径的报错
+        return byUserDir;
+    }
+
+    @PreDestroy
+    @Override
+    public void close() {
+        engine.close();
+    }
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/embedding/vo/EmbeddingDebugVO.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.embedding.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 嵌入调试结果:向量维度、耗时、各文本向量与两两余弦相似度矩阵
+ */
+@Data
+public class EmbeddingDebugVO {
+
+    /** 输出向量维度(bge-m3 为 1024) */
+    private int dimension;
+
+    /** 本次嵌入耗时(毫秒) */
+    private long embedMillis;
+
+    /** 与输入顺序一致的向量(已 L2 归一化) */
+    private List<float[]> vectors;
+
+    /** 两两余弦相似度矩阵(similarity[i][j] 为第 i 条与第 j 条文本的相似度) */
+    private List<List<Double>> similarity;
+}

+ 23 - 0
ai-server/src/main/resources/application.yaml

@@ -78,6 +78,29 @@ zsjz:
     # 单个文件识别的模型调用超时(秒)
     timeout-seconds: 120
 
+  # 本地嵌入模型(进程内 ONNX 推理,独立于 agent_model 表的远程嵌入配置)。
+  # 默认 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: true
+    # 模型目录:含 tokenizer.json 与 onnx/model.onnx(+ model.onnx_data 外部权重)。
+    # 相对路径优先按工作目录(user.dir)解析,其次按进程当前目录解析
+    model-path: bge-m3
+    # 单文本最大 token 数(超出截断;bge-m3 上限 8192,长文本 CPU 推理耗时显著上升)
+    max-seq-len: 8192
+    # 批量推理每批文本条数(批内按最长文本 padding)
+    batch-size: 8
+    # ORT intra-op 并行线程数;0 = 交给 ONNX Runtime 默认策略
+    intra-op-threads: 0
+    # ONNX 图优化级别:NO_OPT / BASIC_OPT / EXTENDED_OPT / ALL_OPT(ALL 首次加载更慢)
+    opt-level: BASIC_OPT
+    # 启动时后台预热加载模型(false = 懒加载,首次业务调用时才载入)
+    eager-load: false
+
 mybatis-plus:
   # 实体别名包(各业务域实体)。已核实 10 个域的实体类简单名无重复,可安全启用;
   # 若后续新增实体出现重名,MyBatis 会因别名冲突启动失败,此时应删除本配置(XML 全用全限定名)。

+ 60 - 0
ai-server/src/test/java/com/zsjz/ai/module/embedding/core/LocalEmbeddingManualTest.java

@@ -0,0 +1,60 @@
+package com.zsjz.ai.module.embedding.core;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 本地嵌入引擎手动验证:加载真实 bge-m3 模型(约 2.3GB,首次 20~60s),验证
+ * 维度、归一化与语义区分度。不依赖 Spring 上下文。
+ *
+ * <p>默认跳过,手动执行:
+ * {@code mvn -pl ai-server test -Dtest=LocalEmbeddingManualTest -Dembedding.manual=true}
+ */
+class LocalEmbeddingManualTest {
+
+    @Test
+    @EnabledIfSystemProperty(named = "embedding.manual", matches = "true")
+    void embedAndCheckQuality() {
+        Path modelDir = Path.of(System.getProperty("user.dir"), "..", "bge-m3").normalize();
+        if (!Files.isDirectory(modelDir)) {
+            modelDir = Path.of("bge-m3");
+        }
+        try (OnnxEmbeddingEngine engine = new OnnxEmbeddingEngine(modelDir, 8192, 0, "BASIC_OPT")) {
+            List<float[]> vectors = engine.embed(List.of(
+                    "转账给可疑账户",
+                    "将资金转移到陌生账户",
+                    "今天天气很好,适合出去散步"));
+
+            assertEquals(3, vectors.size());
+            assertEquals(1024, engine.getDimension(), "bge-m3 输出应为 1024 维");
+
+            for (float[] vector : vectors) {
+                double norm = 0;
+                for (float v : vector) {
+                    norm += (double) v * v;
+                }
+                assertEquals(1.0, Math.sqrt(norm), 1e-3, "向量应已 L2 归一化");
+            }
+
+            double related = cosine(vectors.get(0), vectors.get(1));
+            double unrelated = cosine(vectors.get(0), vectors.get(2));
+            assertTrue(related > unrelated,
+                    "语义相关句对相似度应更高: related=" + related + ", unrelated=" + unrelated);
+        }
+    }
+
+    private static double cosine(float[] a, float[] b) {
+        double dot = 0;
+        for (int i = 0; i < a.length; i++) {
+            dot += (double) a[i] * b[i];
+        }
+        return dot;
+    }
+}