Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步

0 阅读14分钟

Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步

作者:鱼宵 | Spring AI 实战精通营 · 第 7 篇

有个朋友想让 AI 回答他公司内部的 FAQ,第一反应是"把文档全塞进提示词"——塞了两份手册,模型一本正经地编答案。问题在哪?大模型的知识训练完就冻结了,它根本不知道你公司的文档;而你要是拿数据库 LIKE '%年假%' 去搜,搜"我想请假出去玩"一个"年假"字都不沾边,照样搜不到。

这就是 RAG(检索增强生成)要解决的事:用户提问时,先从你的资料里"语义相关"地捞出片段,再拼进提示词让模型答。这篇文章只讲 RAG 的前一半——怎么把文本变成可搜索的坐标。代码全在仓库的 lesson-07/ 目录,clone 下来照着跑,你会亲眼看到一个有点魔法的现象:搜"喉咙堵",命中的是"咽不下去"那句,俩话一个相同的实义字都没有。而且这节课全程本机跑,零 API Key、零外网调用、零成本。

一、核心原理:文本怎么变成"坐标"

1. Embedding:给每句话在语义地图上定位

**Embedding(嵌入)**就是一个函数:输入一段文本,输出一个固定长度的数字数组(本课是 384 维,即 384 个浮点数)。

类比:把每段话都投到一张 384 维的"语义地图"上,意思相近的话落在地图相邻的位置。"我心情不好吃不下饭"和"咽不下去"虽然用字不同,但意思相近,地图上就挨得近。为什么需要它?——计算机不会直接比较两句话"像不像",但很会比较两组数字"近不近"。嵌入就是把"语义相似度"翻译成"向量距离"。

2. distance:地图上的直线距离

两句话的向量拿到后算一个距离(本课 SimpleVectorStore 默认余弦距离,范围 0~2):

  • distance 越小 = 两个向量方向越一致 = 语义越像;
  • 0 表示几乎完全同向,2 表示方向相反。

类比地图测距:你搜"附近的咖啡店",手机按直线距离排序——distance 就是这个直线距离,越近排越前。初写检索的人常把它搞反,记住"越小越像"。

3. VectorStore:统一的"国标插座"

VectorStore 是 Spring AI 定义的统一接口,核心就两个方法:add(文档列表) 入库、similaritySearch(查询) 检索。

类比国标插座:不管你买内存实现、Redis、PgVector 还是 Milvus,插头形状统一(同一个 VectorStore 接口),插上去就能用。本课用 SimpleVectorStore(纯内存,关机即丢)教学;生产换成 Redis 向量库,业务代码一行不动。

4. 本地 ONNX:绿色软件式跑模型

ONNX(Open Neural Network Exchange)是模型的"通用集装箱格式":PyTorch 训练好的模型导出成 .onnx 文件,任何装了 ONNX Runtime 的机器都能跑,不依赖 Python、不依赖 GPU。类比把一个要装一堆依赖的程序压成单文件绿色版,拷过来双击就跑。

本课用 all-MiniLM-L6-v2:输出 384 维向量,模型文件 86MB,CPU 上毫秒级出向量,文件已经随工程打包在 resources/models/ 下,开箱即用。

5. 错误写法 vs 正确写法

错误:拿数据库 LIKE '%喉咙%' 去搜
     → "咽不下去"里没有"喉咙"两个字,直接漏检——关键字匹配不懂语义

正确:把查询和语料都嵌成向量,按 distance 排序
     → 字面不沾边但意思相近的句子,照样排到最前

二、动手:这节课连 Key 都不用配

环境:Windows + JDK 17 + Maven 3.9+。不需要任何 API Key——嵌入模型跑在本机 ONNX Runtime 上。

第 1 步:检查环境(注意,没有 Key 那一步)。

java -version                                  # 期望 True
Get-NetTCPConnection -LocalPort 8099 -State Listen -ErrorAction SilentlyContinue   # 无输出=空闲

第 2 步:编译 + 启动。

cd spring-ai-journey\lesson-07
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"   # Maven 跑在 JDK 17 上
mvn clean install -DskipTests
mvn spring-boot:run
# 看到 Tomcat started on port 8099,还有一行"语料入库完成:共 5 段"即成功(本地模型 2~3 秒启动)

第 3 步:调两个接口。

# ① 向量维度自检:看嵌入模型到底吐出多少维
Invoke-RestMethod "http://localhost:8099/dim"

# ② 语义检索(中文要 URL 编码)
$q = [uri]::EscapeDataString('我今天心情不好不想吃饭')
Invoke-RestMethod "http://localhost:8099/search?q=$q"

三、关键代码:四个类,串起建库到检索

第一段:application.yml——模型指向本机文件,不联网。

server:
  port: 8099                       # 端口按课程分配表:lesson-07 = 8099
spring:
  ai:
    embedding:
      transformer:
        # 本地模型随工程打包在 classpath:models/(tokenizer.json + model.onnx,all-MiniLM-L6-v2)。
        # starter 默认会从 GitHub 远程下载,国内直连常超时;
        # 显式指向 classpath 后启动不联网、秒开。本地模型没有 Key 的概念。
        onnx:
          model-uri: classpath:models/model.onnx
        tokenizer:
          uri: classpath:models/tokenizer.json

第二段:VectorStoreConfig.java——手工 new 一个内存向量库。

package com.springai.lesson07;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 向量库配置:把"嵌入模型 + 内存向量库"组装成一个 VectorStore Bean。
 * SimpleVectorStore 是教学/测试用的内存实现,starter 不自动装配,所以这里手工声明 @Bean。
 * 换成 Redis 向量库时,这个 @Bean 整个删掉,改引 starter 自动装配即可,业务代码不动。
 */
@Configuration
public class VectorStoreConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        // builder 传入嵌入模型:入库和查询时都要用它把文本变成向量
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

第三段:DataLoader.java——启动时把 5 段语料嵌成向量存进去。

package com.springai.lesson07;

import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

/**
 * 语料入库器:启动完成后自动把 5 段中文语料写进向量库(对应 RAG 的"建库"阶段)。
 * ApplicationRunner:Spring Boot 启动完自动跑一次 run(),很适合初始化数据。
 */
@Component
public class DataLoader implements ApplicationRunner {

    private final VectorStore vectorStore;

    public DataLoader(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    @Override
    public void run(ApplicationArguments args) {

        // 5 段语料故意设计成两组:字面不相关但语义相关、字面相关但语义相反——
        // 用来检验嵌入模型是不是真懂语义,而不是按关键字匹配。
        List<Document> documents = List.of(
                // 句1:情绪低落 + 没胃口
                new Document("我今天心情不好,完全没胃口,什么都吃不下",
                        Map.of("source", "句1", "tag", "情绪低落/没胃口")),
                // 句2:美食(带"饭"字,但语义是"好吃",和"不想吃饭"相反)
                new Document("这家店招牌红烧肉肥而不腻,特别下饭,我连吃了三碗",
                        Map.of("source", "句2", "tag", "美食/下饭")),
                // 句3:咽不下去(和句1语义相近,但字面一个"饭"字都没有)
                new Document("总觉得喉咙里堵着东西,咽不下去,勉强吃一点就想吐",
                        Map.of("source", "句3", "tag", "身体不适/吞咽困难")),
                // 句4:户外风景(和吃饭情绪无关)
                new Document("周末去郊外爬山,山顶视野开阔,风景特别好",
                        Map.of("source", "句4", "tag", "户外/风景")),
                // 句5:情绪调节(带"心情不好",但语义是"解决办法")
                new Document("心情不好的时候听听歌、散散步,很快就能平静下来",
                        Map.of("source", "句5", "tag", "情绪调节"))
        );

        // add() 内部做两件事:调嵌入模型把每段文本变成向量,然后存进内存库
        vectorStore.add(documents);

        System.out.println("========== 语料入库完成:共 " + documents.size() + " 段 ==========");
    }
}

第四段:VectorSearchController.java——两个检索接口。

package com.springai.lesson07;

import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 语义检索接口:本课核心演示入口。
 *   GET /dim          —— 返回嵌入模型输出的向量维度(眼见为实 all-MiniLM 是 384 维)
 *   GET /search?q=... —— 把查询嵌入成向量,找距离最近的 topK 段文本返回
 */
@RestController
public class VectorSearchController {

    private final VectorStore vectorStore;
    private final EmbeddingModel embeddingModel;

    public VectorSearchController(VectorStore vectorStore, EmbeddingModel embeddingModel) {
        this.vectorStore = vectorStore;
        this.embeddingModel = embeddingModel;
    }

    /** 向量维度自检:embed() 返回 float[],length 就是维度 */
    @GetMapping("/dim")
    public Map<String, Object> dim() {
        float[] vector = embeddingModel.embed("维度自检文本");
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("embeddingModel", embeddingModel.getClass().getSimpleName());
        result.put("dimension", vector.length);
        result.put("firstFiveNumbers", List.of(vector[0], vector[1], vector[2], vector[3], vector[4]));
        return result;
    }

    /**
     * 语义检索:similaritySearch 内部把 q 也嵌成向量,和库中每条向量算距离排序取 topK。
     * distance 越小越相似(SimpleVectorStore 默认余弦距离,0=完全同向,2=反向)。
     */
    @GetMapping("/search")
    public Map<String, Object> search(@RequestParam("q") String q,
                                      @RequestParam(value = "topK", defaultValue = "3") int topK) {

        List<Document> hits = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(q)       // 查询语句(会被嵌入成向量)
                        .topK(topK)     // 返回最相似的前 K 条
                        .build()
        );

        List<Map<String, Object>> items = hits.stream().map(doc -> {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("content", doc.getText());                        // 命中的原文
            item.put("metadata", doc.getMetadata());                   // 入库时带的 source/tag
            item.put("distance", doc.getMetadata().get("distance"));   // 距离:越小越相似
            return item;
        }).toList();

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("query", q);
        result.put("hitCount", items.size());
        result.put("hits", items);
        return result;
    }
}

第五段:Lesson07Application.java——标准启动类。

package com.springai.lesson07;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Spring AI 实战精通营 · 第 7 课:RAG 上(向量库与嵌入)。
 * 启动时自动把 5 段中文语料嵌成向量存入内存库;访问 /search 做语义检索。
 * 本课零 API 成本:嵌入模型 all-MiniLM-L6-v2 跑在本机 ONNX Runtime。
 */
@SpringBootApplication
public class Lesson07Application {

    public static void main(String[] args) {
        SpringApplication.run(Lesson07Application.class, args);
    }
}

四、实测输出:搜"喉咙堵",命中"咽不下去"

以下是 2026-10-05 本机真实运行。先看 /dim,眼见为实 384 维:

{
  "embeddingModel": "TransformersEmbeddingModel",
  "dimension": 384,
  "firstFiveNumbers": [0.14497744, 0.5271977, 0.4065016, -0.093232825, -0.029930325]
}

dimension=384 证实 all-MiniLM-L6-v2 输出 384 维向量;后面那串数字就是向量的前五个分量——每句话都会被表示成这样一串数字。

再看 /search,第一个查询:"我今天心情不好不想吃饭":

命中 top3(distance 越小越相似):
[1] distance=0.1097  tag=情绪低落/没胃口   ← 句1,几乎一样的话,最近
[2] distance=0.2840  tag=情绪调节         ← 句5,带"心情不好",次之
[3] distance=0.3484  tag=美食/下饭        ← 句2,含"饭"字但语义相反,排最后

注意句2:它带"饭"字,字面上和查询最像,可因为语义是"好吃",反而排第三——关键字匹配会把它排第一,语义检索没干这事。

第二个查询更绝:"喉咙里堵得慌,咽东西难受":

命中 top3:
[1] distance=0.0654  tag=身体不适/吞咽困难  ← 句3"咽不下去",排第一!
[2] distance=0.3923  tag=户外/风景
[3] distance=0.3991  tag=美食/下饭

这就是 RAG 的核心魔法:查询和句3 没有一个相同的实义字("堵/难受" vs "堵着东西/咽不下去"),但嵌入模型把它们的语义拉到了相邻位置,distance 只有 0.0654。它懂意思,不是按关键字匹配。 你拿 LIKE '%喉咙%' 去搜,句3 根本不会命中。

排查提示:启动报 "Failed to cache the resource",是模型 URI 没指向 classpath 还想联网下,确认 yml 里 classpath:models/model.onnx;注入 EmbeddingModel 报 NoSuchBean,是 pom 漏了 spring-ai-starter-model-transformers。

五、挑战题:改一改,看看会怎样

  1. ⭐ 调 topK:同一个查询,分别请求 /search?q=...&topK=1 和 /search?q=...&topK=5,对比返回条数。想一个问题:为什么 RAG 不能把全部片段都塞给模型?答案在 VectorSearchController 的 topK 参数和第二段语料设计里。
  2. ⭐⭐ 加一段语料:在 DataLoader 里加第 6 段文本(比如一句你自己的 FAQ),重启后查一句相关的话,验证"新词入库即可搜"——想想生产里文档更新了要怎么办。
  3. ⭐⭐⭐ 对比关键字搜索:用 SQL LIKE 或 Java contains 实现一遍同样的查询,搜"喉咙堵"为什么关键字搜不到句3?把 RAG 语义检索和关键字搜索的差别写 200 字总结——这就是 RAG 的本质。

六、生产环境进阶:三个加分项

1. SimpleVectorStore 关机即丢,生产要换真库。 它是内存 Map,重启数据全没。生产换成 spring-ai-starter-vector-store-redis(或 PgVector、Milvus),把 VectorStoreConfig 里那个 @Bean 删掉改用 starter 自动装配,Controller 一行不动——这就是"国标插座"的兑现。

2. 中文场景换中文嵌入模型。 all-MiniLM 是英文优化、中文教学演示够用;生产中文知识库换 bge-small-zh 等中文模型,VectorStore 接口不变,只换模型文件。

3. 本地 ONNX 模型别让它联网下载。 starter 默认 URI 指向 GitHub raw,国内直连常超时,而且大文件走 LFS 直接 Invoke-WebRequest 只拿到 133 字节的指针文件。提前把真模型放 resources/models/,yml 显式配 classpath:。

七、面试回答模板

面试官:什么是 Embedding?为什么"语义相近"的两句话,向量也相近?

一句话:嵌入把一段文本映射成固定长度的数字向量,语义相近则向量在空间里相邻。展开:它把"语义相似度"翻译成"向量距离",计算机才好比较——计算机不会读句子,但很会算两组数字的远近。类比给每句话在语义地图上定坐标,意思差不多的话落在相邻位置。(指向本课第一节 / lesson-07 的 DataLoader 语料)

追问:Spring AI 的 VectorStore 抽象是什么?为什么换 Redis/PgVector 业务代码不改?

一句话:VectorStore 是统一接口,核心就 add() 和 similaritySearch() 两个方法。展开:不管背后是内存、Redis 还是 Milvus,Controller 只认这个接口;换实现只改 pom 依赖和配置,业务代码一行不动——像国标插座,插头牌子随便换。(指向本课第三节 VectorStoreConfig)

追问:distance 是越小越相似吗?

对。SimpleVectorStore 用余弦距离,范围 0~2,0 是完全同向(几乎同义)、2 是方向相反;检索结果按距离升序排,越靠前越像。别搞反成"越大越像"。(指向本课第一、四节)

追问:为什么 RAG 用本地 ONNX 嵌入,而不是调在线 embedding 接口?

本地嵌入三件好处:零 API 成本、数据不出本机、离线可用;代价是模型小、效果弱于在线大 embedding。教学和敏感数据场景用本地,追求效果再换在线模型——VectorStore 接口不变。(指向本课第一节和第六节)

八、总结表

坑现象解法
模型默认从 GitHub 下载启动 SocketException 超时模型放 resources,yml 配 classpath:models/
GitHub raw 下大文件只拿到 133 字节 LFS 指针从 LFS 仓库/镜像下真 model.onnx(86MB)
embed() 返回 float[]写成 double[] 编译报错Spring AI 1.0.x 嵌入返回 float[]
SimpleVectorStore 忘声明 @Bean注入 VectorStore 失败VectorStoreConfig 里手工 @Bean
distance 搞反以为越大越像余弦距离 0=同向,越小越像
关键字 LIKE 检索搜"喉咙堵"漏检"咽不下去"用语义向量检索,不懂字面要懂意思

九、关于这个系列

本文是「Java 后端实战精通营」系列第 7 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-07/(Web 工程,内含 VectorStoreConfig 组装向量库 + DataLoader 启动入库 + VectorSearchController 检索双接口,本地 ONNX 模型已打包)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官

下一篇预告:《Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话》——本课你已经能"按语义捞出片段",下一课把它和大模型串起来:从 pdf/md 自动加载切分,用 QuestionAnswerAdvisor 自动完成"检索→把片段塞进提示词→生成带引用的回答"。你会做出一个真正能用的 FAQ 客服助手,还能问文档外的问题看它怎么兜底。

跑完有任何报错,把终端输出发评论区,一起排查。


标签建议:SpringAI、向量检索、Embedding 摘要建议(≤256 字):搜"我不想吃饭",关键字 LIKE 搜不到"咽不下去"——因为它俩一个相同字都没有,但意思很近。这就是向量检索要解决的事。本文用 Spring AI 的本地 ONNX 嵌入模型 all-MiniLM-L6-v2,把文本变成 384 维坐标,存进内存向量库,实测搜"喉咙堵"命中"咽不下去"。全程零 API Key 零成本,逐行拆解建库与检索四个类,附 distance 排序、VectorStore 抽象和面试回答模板,源码已开源 lesson-07 可 clone 直接跑。