ElasticSearch+RAG初使用

​

Elastic Search+RAG的入门使用

​


本文档中的指导内容,其实纯依靠问AI基本也能完成(建议用deepseek,不要用豆包等其他国产AI),只不过会有些坑在本文档中提前说明了。

一、ES

1:环境准备与版本选择

JDK17、SpringBoot3.5.16、Docker Desktop中的ES8

正式学习 ES,建议以 Docker 为主、Linux 虚拟机为辅,Windows 原生版只做临时体验。 如果只能三选一:开发/应用方向选 Docker;运维/架构方向选 Linux 虚拟机。

Windows版的ES会污染主机:Windows 原生版 ES 会对操作系统做很多持久、分散、难以一键回滚的更改,导致主机环境不再干净

🐳 2:安装并运行

Elasticsearch(服务,自带 RestAPI 能力,9200 端口)

Kibana(Web 可视化工具,用来在 DevTools 里写 DSL 调用 ES 的 RestAPI)

Elasticsearch 自带的标准分词器 对中文的支持很差,它会把一整句中文切成一个个单独的字,这会让搜索变得非常不准确。IK 分词器是中文全文搜索的标配,版本必须与 ES 完全一致。

Java High Level Rest Client(Java 客户端 jar 包,是项目 maven 依赖,不是服务器安装程序)

2.1安装并运行 Elasticsearch

2.1.1.调整 Docker Desktop 资源

ES 是个内存消耗大户,为了保证它能流畅运行,请先确保 Docker Desktop 有足够的内存。

  • 打开 Docker Desktop,进入 Settings -> Resources。
  • 在 Advanced 中,将 Memory 调整为至少 4GB ,如果主机内存充裕,建议给到 8GB。
2. 拉取 Elasticsearch 镜像

打开终端(PowerShell 或 CMD),执行以下命令拉取官方镜像。建议明确指定版本号,这里我们使用 8.15.1 作为示例。

bash 复制代码
docker pull docker.elastic.co/elasticsearch/elasticsearch:8.15.1
3. 创建 Docker 网络

为了让 Kibana 能够顺利连接到 ES,我们需要先创建一个专用的 Docker 网络。

lua 复制代码
docker network create elastic-net
4. 启动 Elasticsearch 容器

执行以下命令启动 ES。为了专注于学习和开发,我们先关闭安全认证,避免被证书问题困扰。

bash 复制代码
docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -p 9300:9300 \
  --network elastic-net \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
  -v elasticsearch_data:/usr/share/elasticsearch/data \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.1
5. 验证 ES 是否启动成功

稍等片刻,然后在浏览器中访问 http://localhost:9200。如果看到返回一个包含 "version" 信息的 JSON,就说明 ES 已经成功运行了。

2.2安装并配置 Kibana

2.2.1. 拉取 Kibana 镜像

注意 :Kibana 的版本必须 与 Elasticsearch 完全一致,这里我们也使用 8.15.1。

bash 复制代码
docker pull docker.elastic.co/kibana/kibana:8.15.1
2.2.2. 启动 Kibana 容器

执行以下命令启动 Kibana。我们将它连接到刚才创建的 elastic-net 网络,并指定 ES 的地址。同时,我们通过环境变量 I18N_LOCALE=zh-CN 来直接启用中文界面。

arduino 复制代码
docker run -d \
  --name kibana \
  -p 5601:5601 \
  --network elastic-net \
  -e ELASTICSEARCH_HOSTS=http://elasticsearch:9200 \
  -e I18N_LOCALE=zh-CN \
  docker.elastic.co/kibana/kibana:8.15.1
2.2.3. 验证并访问 Kibana

等待 1-2 分钟,让 Kibana 完成启动。然后在浏览器中访问 http://localhost:5601。如果看到中文界面的 Kibana 主页,就说明成功了。

关键入口 :点击左侧菜单栏的 "开发工具" (Dev Tools) ,你就可以在这里编写和执行 DSL 查询语句了。

2.3安装中文分词器 IK

2.3.1. 确认 ES 容器正在运行

打开 PowerShell 或 CMD,执行:

复制代码
docker ps

你应该能看到类似这样的输出,注意容器名 elasticsearch 和版本 8.15.1:

bash 复制代码
CONTAINER ID   IMAGE                                                  ...   NAMES
xxxxxx         docker.elastic.co/elasticsearch/elasticsearch:8.15.1   ...   elasticsearch

如果容器名不是 elasticsearch,请记下实际名字,下面命令里的 elasticsearch 都要替换成你的容器名。

2.3.2. 进入 ES 容器
bash 复制代码
docker exec -it elasticsearch /bin/bash

执行后,终端提示符会变成类似 elasticsearch:/usr/share/elasticsearch#,表示你已经进入容器内部。


2.3.3. 安装 IK 分词器插件

在容器内执行:

bash 复制代码
./bin/elasticsearch-plugin install https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.15.1.zip

注意 :URL 中的 8.15.1 必须与你的 ES 版本完全一致。如果你用的是其他版本,请替换成对应版本号。

执行后会提示是否继续,输入 y 并回车。安装成功后会出现类似 Installed analysis-ik 的提示。


2.3.4. 退出容器并重启 ES
bash 复制代码
exit

然后重启 ES 容器,让插件生效:

复制代码
docker restart elasticsearch

等待几秒钟,让 ES 完成启动。

2.3.5. 验证 IK 分词器是否安装成功

有两种验证方式:

方式一:用 Kibana DevTools(推荐)

打开浏览器访问 http://localhost:5601,进入 开发工具,执行:

json 复制代码
POST _analyze
{
  "analyzer": "ik_max_word",
  "text": "人工智能大模型RAG检索增强生成"
}

如果返回了合理的分词结果(比如 人工智能、人工、智能、大 等),说明 IK 安装成功。

方式二:

Kibana 返回的分词结果完全正常,说明 ES 和 IK 都装对了、工作也正常。PowerShell 那个空结果是纯环境问题,跟 ES 本身无关。

为什么 PowerShell 会失败?

核心原因是:Windows PowerShell 5.x 默认不是用 UTF-8 处理字符串和外部请求的。

具体来说有几层坑叠加:

  1. 控制台代码页 Windows 中文环境下,PowerShell 控制台默认代码页是 936(GBK),不是 65001(UTF-8)。中文字符在传递给 HTTP 请求时会先按 GBK 编码,再被当成别的编码解析,最后变成乱码。
  2. Invoke-RestMethod 的默认行为 在 PowerShell 5.x 中,如果 Body 是字符串,它会按内部编码转成字节,再按 Content-Type 里声明的字符集(或默认)发送。你不指定 charset=utf-8 时,实际发出去的中文可能已经损坏。
  3. ES 收到的是"看起来是合法 JSON 但内容是乱码"的请求 所以 ES 没报 JSON 语法错误,而是正常执行了分词,只是它拿到的 text 不是"我爱北京天安门",分词结果自然为空。
  4. Kibana 没这个问题 浏览器天然用 UTF-8 发请求,Kibana 也明确按 UTF-8 处理,所以一切正常。

这不是 ES 的问题,也不是你命令写错,是 Windows PowerShell 5.x 在中文编码上的老毛病。

如果以后还想在 PowerShell 里验证 ES,记住两个写法

写法一:显式 UTF-8 字节数组(最可靠)

swift 复制代码
$body = '{"analyzer":"ik_max_word","text":"我爱北京天安门"}'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Uri "http://localhost:9200/_analyze" -Method Post -ContentType "application/json; charset=utf-8" -Body $bytes | ConvertTo-Json -Depth 5

写法二:改用 PowerShell 7(pwsh) PowerShell 7 默认 UTF-8,就不会有这个坑。可以在微软官网单独安装,和 Windows PowerShell 5.x 共存。


2.3.6. 安装 IK 后,Java 代码需要改什么?

不需要改依赖,也不需要改连接配置。 但如果你的实体类里想用 IK 分词器,可以在 @Field 注解中指定:

typescript 复制代码
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
private String name;

这样 ES 在建立索引和搜索时就会使用 IK 分词器。如果你不指定,默认会用标准分词器,中文搜索效果会差很多。

2.4可能的疑虑

如果这些东西是以Docker中的容器运行,我在Docker外部的Windows系统中写Java代码时是否需要调整代码写法?

结论:不需要调整 Java 代码的写法,只需要调整连接配置。 Docker 只是改变了 Elasticsearch 的运行位置和网络入口,并没有改变它的 API、协议或 Java 客户端的用法。

唯一要改的是"连接地址"(后文会提到)

3:环境就绪进入项目阶段

3.1.在 IDEA 中新建 Spring Boot 项目

JDK:17

Spring Boot:3.5.16

Type:Maven

3.2.检查 pom.xml

打开 pom.xml,确认有这两个依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

3.3.配置 application.yml

yaml 复制代码
server:
  port: 8080

spring:
  application:
    name: es-demo
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 5s
    socket-timeout: 30s

说明:

  • uris:ES 地址。因为我们通过 Docker 做了端口映射 -p 9200:9200,Windows 主机上的 Java 程序访问 localhost:9200 即可。
  • 不需要用户名密码,因为前面启动 ES 时关闭了安全认证。

3.4.创建包结构(参考)

javascript 复制代码
com.example.esdemo
├── EsDemoApplication.java   (启动类,已有)
├── document/                (实体类)
├── repository/              (Repository)
├── service/                 (业务层)
└── controller/              (接口层)

3.5.实体类:document/Product.java

typescript 复制代码
package com.example.esdemo.document;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

@Document(indexName = "product")
public class Product {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
    private String name;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
    private String description;

    @Field(type = FieldType.Double)
    private Double price;

    @Field(type = FieldType.Keyword)
    private String category;

    public Product() {
    }

    public Product(String id, String name, String description, Double price, String category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    // getter / setter

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }

    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}

关键点说明:

  • @Document(indexName = "product"):声明这个类对应 ES 中的 product 索引。应用启动时,如果索引不存在,Spring Data 会根据实体自动创建。

  • @Id:文档主键。保存时如果指定了 id,会用它作为 _id。

  • @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word"):

    • Text 类型支持全文搜索。
    • analyzer 指定写入时用 IK 分词。
    • searchAnalyzer 指定查询时也用 IK 分词。
    • 这两个字段是中文搜索效果好的关键。
  • @Field(type = FieldType.Keyword):category 用于精确匹配、聚合,不分词。

  • @Field(type = FieldType.Double):价格用数值类型,方便范围查询和排序。

3.6.Repository:repository/ProductRepository.java

arduino 复制代码
package com.example.esdemo.repository;

import com.example.esdemo.document.Product;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import java.util.List;

public interface ProductRepository extends ElasticsearchRepository<Product, String> {

    // 方法名自动解析成查询:对 name 字段做 match 查询
    List<Product> findByName(String name);

    // 按分类精确匹配
    List<Product> findByCategory(String category);

    // 价格区间
    List<Product> findByPriceBetween(Double min, Double max);
}

说明:

  • ElasticsearchRepository<Product, String>:泛型分别是实体类型和主键类型。继承后自动获得 save、findById、findAll、deleteById、count 等方法。
  • findByName 等方法名会被 Spring Data 自动解析为 ES 查询,不需要你写实现。
  • 后续想写复杂 DSL,可以注入 ElasticsearchOperations 或 ElasticsearchClient。

3.7.Service:service/ProductService.java

typescript 复制代码
package com.example.esdemo.service;

import com.example.esdemo.document.Product;
import com.example.esdemo.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    public Product save(Product product) {
        return productRepository.save(product);
    }

    public Optional<Product> findById(String id) {
        return productRepository.findById(id);
    }

    public Iterable<Product> findAll() {
        return productRepository.findAll();
    }

    public void deleteById(String id) {
        productRepository.deleteById(id);
    }

    public List<Product> searchByName(String name) {
        return productRepository.findByName(name);
    }

    public List<Product> searchByCategory(String category) {
        return productRepository.findByCategory(category);
    }

    public List<Product> searchByPriceRange(Double min, Double max) {
        return productRepository.findByPriceBetween(min, max);
    }
}

3.8.Controller:controller/ProductController.java

less 复制代码
package com.example.esdemo.controller;

import com.example.esdemo.document.Product;
import com.example.esdemo.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/products")
public class ProductController {

    @Autowired
    private ProductService productService;

    // 新增或更新
    @PostMapping
    public Product save(@RequestBody Product product) {
        return productService.save(product);
    }

    // 按 id 查询
    @GetMapping("/{id}")
    public Optional<Product> findById(@PathVariable String id) {
        return productService.findById(id);
    }

    // 查询全部
    @GetMapping
    public Iterable<Product> findAll() {
        return productService.findAll();
    }

    // 按 id 删除
    @DeleteMapping("/{id}")
    public String delete(@PathVariable String id) {
        productService.deleteById(id);
        return "deleted: " + id;
    }

    // 按名称搜索
    @GetMapping("/search")
    public List<Product> searchByName(@RequestParam String name) {
        return productService.searchByName(name);
    }

    // 按分类搜索
    @GetMapping("/category")
    public List<Product> searchByCategory(@RequestParam String category) {
        return productService.searchByCategory(category);
    }

    // 按价格区间搜索
    @GetMapping("/price")
    public List<Product> searchByPriceRange(@RequestParam Double min,
                                            @RequestParam Double max) {
        return productService.searchByPriceRange(min, max);
    }
}

3.9.启动应用

3.10.验证功能

3.10.1. 添加一条数据

用 PowerShell 的 Invoke-RestMethod(避免之前的编码问题,我们这次用英文+中文混合,且用 UTF-8 字节):

swift 复制代码
$body = '{"id":"1","name":"Apple iPhone 15","description":"苹果手机 最新款","price":7999.00,"category":"phone"}'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Uri "http://localhost:8080/products" -Method Post -ContentType "application/json; charset=utf-8" -Body $bytes | ConvertTo-Json -Depth 5

再添加两条:

swift 复制代码
$body = '{"id":"2","name":"小米 14 Pro","description":"小米旗舰手机 骁龙8Gen3","price":4999.00,"category":"phone"}'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Uri "http://localhost:8080/products" -Method Post -ContentType "application/json; charset=utf-8" -Body $bytes | ConvertTo-Json -Depth 5

$body = '{"id":"3","name":"华为 MateBook X Pro","description":"华为轻薄笔记本","price":12999.00,"category":"laptop"}'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Uri "http://localhost:8080/products" -Method Post -ContentType "application/json; charset=utf-8" -Body $bytes | ConvertTo-Json -Depth 5
3.10.2. 在 Kibana 中确认索引和数据

浏览器打开 http://localhost:5601 → 开发工具:

bash 复制代码
GET product/_search
{
  "query": {
    "match_all": {}
  }
}

应该能看到三条文档。

3.10.3. 测试中文分词搜索

用 Kibana 直接调 ES(绕开 PowerShell 编码问题):

bash 复制代码
GET product/_search
{
  "query": {
    "match": {
      "name": "小米"
    }
  }
}

应该返回 小米 14 Pro,说明 IK 分词 + 中文搜索已经生效。

3.10.4. 测试 Java 接口

用浏览器或 PowerShell 访问:

bash 复制代码
http://localhost:8080/products/search?name=iPhone
http://localhost:8080/products/search?name=小米
http://localhost:8080/products/category?category=phone
http://localhost:8080/products/price?min=4000&max=8000

如果用 PowerShell 传中文,记得用 UTF-8 字节写法:

ini 复制代码
$name = "小米"
$url = "http://localhost:8080/products/search?name=" + [System.Web.HttpUtility]::UrlEncode($name)
Invoke-RestMethod -Uri $url -Method Get | ConvertTo-Json -Depth 5
3.10.5.可能的疑问

Windows 系统层面的内存/页面文件不足

PowerShell 乱码 :只是 PowerShell 显示层的问题,其实程序完全正常。

ProductRepository 中的几个知识接口,没有实现,为什么在ProductService还能调用它们:

这是 Spring Data 最核心、也最"魔法"的一个特性,叫 方法名派生查询(Query Derivation) 。

底层通过动态代理,在启动时根据方法名生成实现逻辑;所以你自己声明的抽象方法,命名必须严格符合 Spring Data 的规范。

与JPA框架(Mybatis的替代品)在"上层架构"和"设计理念"上几乎是完全一样的;但在"底层查询翻译"和"执行引擎"上它们完全不同。

Repository ≈ Mapper 层,但 Repository 的抽象层次更高,自动生成实现的能力更强。

4:项目跑通后的学习

4.1.补 ES 核心概念

需要搞懂的概念

概念 类比 MySQL 说明
Index 数据库/表 一类文档的集合,如 product
Document 一行记录 ES 里的最小数据单元
Field 列 文档的属性
Mapping 表结构 定义字段类型、分词器
Shard 分库分表 索引的水平切分
Replica 从库 分片的副本,提供高可用
Analyzer 无 分词器,全文搜索的核心

练习

在 Kibana DevTools 里执行:

bash 复制代码
# 查看 product 索引的 mapping,看你实体类的 @Field 是怎么映射的
GET product/_mapping

# 查看索引设置
GET product/_settings

# 查看集群健康
GET _cluster/health

# 查看分片分布
GET _cat/shards?v

目标 :能说清楚 Text 和 Keyword 的区别,知道 ik_max_word 在写入和查询时分别起什么作用。

4.2.在 Kibana 里玩透 DSL

这是 ES 学习的核心。Kibana DevTools 是最好的练习场,因为不用折腾 PowerShell 编码。

1.基础查询

2.排序、分页、高亮

3.聚合

4.3.把 DSL 搬回 Java

1.用 @Query 注解写 DSL

2.用 ElasticsearchOperations + NativeQuery

3.分页与排序

4.高亮

5.聚合

目标:把 Kibana 里练过的每个 DSL,都在 Java 里实现一遍,形成自己的代码模板。

4.4.做一个"像样"的小项目

把 ES 用到一个有真实业务感的场景里,比单纯练 API 收获大得多。

推荐场景:商品搜索系统

功能清单:

  1. 商品管理 CRUD:你已经有了。
  2. 多条件组合搜索:关键词 + 分类 + 价格区间 + 排序 + 分页。
  3. 搜索关键词高亮:返回结果里把匹配的词标红。
  4. 搜索建议(自动补全) :用 completion 类型或 match_phrase_prefix。
  5. 聚合筛选面板:按分类统计数量,按价格区间统计。
  6. 热门搜索统计:记录用户搜索词,做聚合分析。
  7. 拼音搜索 :安装 analysis-pinyin 插件,支持"xiaomi"搜到"小米"。

技术点覆盖

  • @Document + @Field 的完整映射
  • 自定义 Analyzer(IK + 拼音)
  • NativeQuery 构建复杂查询
  • 高亮、聚合、分页、排序
  • 异常处理和参数校验

二、ES 向量搜索

提示和疑问解答:

  • 先补上 ES 的向量搜索能力,再过渡到 RAG:RAG 的"检索"部分,在 ES 里就是向量搜索 + 混合搜索。现在只学了传统全文检索,直接跳到 RAG,会像"还没学会走路就去跑酷"------能跑通 demo,但底层一知半解,出了问题完全无法调试

  • ES 8.x 已经内置了向量检索(dense_vector + kNN),它现在是主流的 RAG 检索层之一。RAG 的典型流程是:

    用户提问 → 向量化 → ES 向量检索/混合检索 → 召回相关文档 → 交给 LLM 生成回答

现在的技能栈(Spring Boot + ES)正好是 RAG 的后端基础设施。补上向量和 LLM 接入,就是一个完整的 RAG 应用

  • ES 服务器端不需要新安装任何东西,向量搜索是 ES 8.x 内置能力。你只需要在 Java 项目里加字段、加查询代码;如果要处理真实文本,再额外接一个 Embedding 来源即可
  • 在现实业务开发中,向量的维度通常不是由你手动"设置"的,而是由你选择的Embedding模型决定的。你几乎不会去手动指定"把这段文本变成768维还是1024维",而是选择一个模型,然后用它输出的维度。、
  • 在模型和数据类型相同的前提下,维度越高,越占空间,而且存储、内存、计算、查询延迟都会线性或更大幅度增长。但更准确地说:占空间不只看维度,还看向量数量、每维字节数、是否建索引、是否量化、ES 是否保留 _source。
  • 明确建议:先用假向量跑通 ES 的 kNN 查询,再接入真实 Embedding 模型。
  • 我的ES里先前已经存了一些ES传统数据(不是向量化的数据)了,现在居然可以直接设置什么索引和向量化后的数据吗?:给索引加一个新字段 和 让已有数据拥有向量,这是两码事。ES 可以给已有索引动态加字段,但已有文档的 embedding 是空的,已有文档不会出现在结果里,因为它们没有向量。kNN 只会在有向量的文档中比较。要让已有数据参与向量搜索,必须回填

1.在 Kibana 里建索引、写向量、跑 kNN

json 复制代码
PUT knowledge
{
  "mappings": {
    "properties": {
      "content": { "type": "text" },
      "embedding": {
        "type": "dense_vector",
        "dims": 3,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

写入三条假向量:

bash 复制代码
POST knowledge/_doc/1
{ "content": "苹果手机", "embedding": [0.9, 0.1, 0.1] }

POST knowledge/_doc/2
{ "content": "小米手机", "embedding": [0.8, 0.2, 0.1] }

POST knowledge/_doc/3
{ "content": "苹果水果", "embedding": [0.1, 0.1, 0.9] }

kNN 查询:

bash 复制代码
GET knowledge/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [0.85, 0.15, 0.1],
    "k": 2,
    "num_candidates": 10
  }
}

2.Kibana 里已经跑通了,现在把同样的流程搬到 Java 项目里

2.1.实体类:document/KnowledgeDoc.java

typescript 复制代码
package com.example.esdemo.document;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

@Document(indexName = "knowledge")
public class KnowledgeDoc {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
    private String content;

    @Field(type = FieldType.Dense_Vector, dims = 3)
    private float[] embedding;

    public KnowledgeDoc() {
    }

    public KnowledgeDoc(String id, String content, float[] embedding) {
        this.id = id;
        this.content = content;
        this.embedding = embedding;
    }

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }

    public float[] getEmbedding() { return embedding; }
    public void setEmbedding(float[] embedding) { this.embedding = embedding; }
}

说明:

  • @Document(indexName = "knowledge") 对应 Kibana 里建的索引名。
  • @Field(type = FieldType.Dense_Vector, dims = 3) 告诉 Spring Data 这个字段是向量,维度是 3。
  • float[] 是 Spring Data 推荐的向量类型。

2.2.Repository:repository/KnowledgeRepository.java

kotlin 复制代码
package com.example.esdemo.repository;

import com.example.esdemo.document.KnowledgeDoc;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface KnowledgeRepository extends ElasticsearchRepository<KnowledgeDoc, String> {
}

这里不写方法名派生查询,因为 kNN 查询用方法名表达不了,我们稍后直接在 Service 里用 ElasticsearchOperations 写。

2.3.Service:service/KnowledgeService.java

这个类承担两个职责:普通 CRUD + kNN 搜索。

java 复制代码
package com.example.esdemo.service;

import com.example.esdemo.document.KnowledgeDoc;
import com.example.esdemo.repository.KnowledgeRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class KnowledgeService {

    @Autowired
    private KnowledgeRepository knowledgeRepository;

    @Autowired
    private ElasticsearchOperations operations;

    // 保存一条文档
    public KnowledgeDoc save(KnowledgeDoc doc) {
        return knowledgeRepository.save(doc);
    }

    // 按 id 查
    public Optional<KnowledgeDoc> findById(String id) {
        return knowledgeRepository.findById(id);
    }

    // 查全部
    public Iterable<KnowledgeDoc> findAll() {
        return knowledgeRepository.findAll();
    }

    // kNN 向量搜索
    public List<KnowledgeDoc> knnSearch(float[] queryVector, int k) {
        List<Float> vectorList = toFloatList(queryVector);

        NativeQuery query = NativeQuery.builder()
                .withKnnSearches(knn -> knn
                        .field("embedding")
                        .queryVector(vectorList)  // 改为传入 List<Float>
                        .k(k)
                        .numCandidates(Math.max(k * 10, 100))
                )
                .build();

        SearchHits<KnowledgeDoc> hits = operations.search(query, KnowledgeDoc.class);
        return hits.stream()
                .map(SearchHit::getContent)
                .toList();
    }

    //KnnSearch.Builder 接口中,queryVector 方法签名是接收 List<Float> 或 Float... 可变参数
    //将 float[] 转换为 List<Float>。在 KnowledgeService 中添加一个辅助方法:
    private List<Float> toFloatList(float[] array) {
        List<Float> list = new ArrayList<>();
        for (float f : array) {
            list.add(f);
        }
        return list;
    }

关键点解释:

  • NativeQuery.builder() 是 Spring Data Elasticsearch 5.x 构建原生查询的方式。
  • withKnnSearches 对应 DSL 里的 "knn": { ... }。
  • .field("embedding") 要和你实体里的字段名一致。
  • .queryVector(queryVector) 传查询向量。
  • .k(k) 是返回的最近邻数量。
  • .numCandidates(...) 是候选集大小,一般设为 k 的 10 倍以上,numCandidates 越大越精确但越慢。
  • operations.search(query, KnowledgeDoc.class) 执行查询,返回 SearchHits。
  • 用 SearchHit::getContent 取出实体对象。

2.4.Controller:controller/KnowledgeController.java

java 复制代码
package com.example.esdemo.controller;

import com.example.esdemo.document.KnowledgeDoc;
import com.example.esdemo.service.KnowledgeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/knowledge")
public class KnowledgeController {

    @Autowired
    private KnowledgeService knowledgeService;

    // 新增或更新
    @PostMapping
    public KnowledgeDoc save(@RequestBody KnowledgeDoc doc) {
        return knowledgeService.save(doc);
    }

    // 按 id 查询
    @GetMapping("/{id}")
    public Optional<KnowledgeDoc> findById(@PathVariable String id) {
        return knowledgeService.findById(id);
    }

    // 查询全部
    @GetMapping
    public Iterable<KnowledgeDoc> findAll() {
        return knowledgeService.findAll();
    }

    // kNN 搜索
    @PostMapping("/knn")
    public List<KnowledgeDoc> knnSearch(@RequestBody KnnRequest request) {
        return knowledgeService.knnSearch(request.getVector(), request.getK());
    }

    // 请求体
    public static class KnnRequest {
        private float[] vector;
        private int k = 3;

        public float[] getVector() { return vector; }
        public void setVector(float[] vector) { this.vector = vector; }

        public int getK() { return k; }
        public void setK(int k) { this.k = k; }
    }
}

说明:

  • POST /knowledge 保存文档,请求体里带 content 和 embedding。
  • POST /knowledge/knn 用向量搜索,请求体里带 vector 和 k。
  • float[] 在 JSON 里就是数组,例如 [0.85, 0.15, 0.1]。

2.5启动并测试

1.启动 Spring Boot

2.验证已有数据能被读到

用浏览器或 Apifox 访问:

bash 复制代码
GET http://localhost:8080/knowledge

应该返回在 Kibana 里写入的那几条假向量文档。如果返回的 embedding 是数组,说明反序列化成功。

3.测试 kNN 搜索

用 Postman 或 Apifox 发:

bash 复制代码
POST http://localhost:8080/knowledge/knn
Content-Type: application/json

{
  "vector": [0.85, 0.15, 0.1],
  "k": 2
}

预期返回和 Kibana 里一样:最接近 [0.85, 0.15, 0.1] 的两条文档。

  1. 用 PowerShell 测试(如果坚持用)

纯数字数组没有编码问题,可以直接用:

swift 复制代码
$body = '{"vector":[0.85,0.15,0.1],"k":2}'
Invoke-RestMethod -Uri "http://localhost:8080/knowledge/knn" -Method Post -ContentType "application/json" -Body $body | ConvertTo-Json -Depth 5

2.6.关于假向量数据,帮我多造几条复杂点的数据knowledge,以方便我测试

1.用 _bulk 批量写入数据

css 复制代码
POST _bulk
{ "index": { "_index": "knowledge", "_id": "1" } }
{ "content": "苹果 iPhone 15 Pro", "embedding": [0.95, 0.05, 0.05] }
{ "index": { "_index": "knowledge", "_id": "2" } }
{ "content": "小米 14 Ultra", "embedding": [0.90, 0.08, 0.02] }
{ "index": { "_index": "knowledge", "_id": "3" } }
{ "content": "华为 MateBook X Pro 笔记本", "embedding": [0.88, 0.06, 0.06] }
{ "index": { "_index": "knowledge", "_id": "4" } }
{ "content": "索尼 WH-1000XM5 耳机", "embedding": [0.85, 0.10, 0.05] }
{ "index": { "_index": "knowledge", "_id": "5" } }
{ "content": "特斯拉 Model Y 电动车", "embedding": [0.70, 0.20, 0.10] }
{ "index": { "_index": "knowledge", "_id": "6" } }
{ "content": "苹果(水果)", "embedding": [0.05, 0.95, 0.05] }
{ "index": { "_index": "knowledge", "_id": "7" } }
{ "content": "香蕉", "embedding": [0.08, 0.90, 0.02] }
{ "index": { "_index": "knowledge", "_id": "8" } }
{ "content": "橙子", "embedding": [0.02, 0.92, 0.06] }
{ "index": { "_index": "knowledge", "_id": "9" } }
{ "content": "西瓜", "embedding": [0.10, 0.85, 0.05] }
{ "index": { "_index": "knowledge", "_id": "10" } }
{ "content": "老虎", "embedding": [0.05, 0.05, 0.90] }
{ "index": { "_index": "knowledge", "_id": "11" } }
{ "content": "大熊猫", "embedding": [0.08, 0.10, 0.82] }
{ "index": { "_index": "knowledge", "_id": "12" } }
{ "content": "金毛犬", "embedding": [0.10, 0.15, 0.75] }
{ "index": { "_index": "knowledge", "_id": "13" } }
{ "content": "《三体》小说", "embedding": [0.30, 0.20, 0.50] }
{ "index": { "_index": "knowledge", "_id": "14" } }
{ "content": "《Java 编程思想》", "embedding": [0.60, 0.10, 0.30] }
{ "index": { "_index": "knowledge", "_id": "15" } }
{ "content": "周杰伦《七里香》", "embedding": [0.20, 0.30, 0.50] }

2.用 kNN 测试不同查询向量

查询偏向"科技产品"的向量

bash 复制代码
GET knowledge/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [0.92, 0.06, 0.02],
    "k": 3,
    "num_candidates": 10
  },
  "_source": ["content"]
}

预期:返回 iPhone、小米、华为笔记本等。

查询偏向"水果"的向量

bash 复制代码
GET knowledge/_search
{
  "knn": {
    "field": "embedding",
    "query_vector": [0.05, 0.93, 0.02],
    "k": 3,
    "num_candidates": 10
  },
  "_source": ["content"]
}

预期:返回苹果(水果)、香蕉、橙子等。

3.在 Java 项目中用接口测试

使用你写好的 POST /knowledge/knn 接口,请求体示例:

json 复制代码
{
  "vector": [0.92, 0.06, 0.02],
  "k": 3
}

三、文本向量化(Embedding)

在目前的测试中,我们为了跑通链路,是手动把文本和假向量一起传进去,搜索时也是手动传假向量。但在真实业务里:

  1. 入库时:用户或系统只提供文本(如商品描述、文章段落),后端通过 Embedding 模型自动生成向量,和文本一起存入 ES。
  2. 搜索时:用户只输入人类语言(如"苹果手机多少钱"),后端先调用同一个 Embedding 模型把问题转成向量,再拿着这个向量去 ES 里做 kNN 搜索。

所以,业务代码里必须有一个专门的接口(或服务)来负责"文本转向量"。这个接口通常叫 EmbeddingService。

业务代码改造(分三步)

1.创建 EmbeddingService

因为我们还没接真实的 AI 模型,先写一个模拟实现,但定义好接口。以后接 OpenAI、Ollama 或通义千问时,只需改这里。

arduino 复制代码
package com.example.esdemo.service;

import org.springframework.stereotype.Service;

import java.util.Random;

@Service
public class EmbeddingService {

    /**
     * 将文本转换为向量。
     * 目前是模拟实现,真实场景需调用 Embedding 模型 API。
     * @param text 用户输入的文本
     * @return 向量数组,维度必须和 ES mapping 里的一致(当前是 3)
     */
    public float[] embed(String text) {
        // 【模拟逻辑】根据文本的 hashCode 生成一个 3 维伪向量
        // 真实场景:这里会发 HTTP 请求给 Embedding 模型
        Random random = new Random(text.hashCode());
        return new float[]{
                random.nextFloat(),
                random.nextFloat(),
                random.nextFloat()
        };
    }
}

注意 :模拟实现只是为了让你跑通流程。真实业务中,这里会替换成 HTTP 调用(比如调 Ollama 的 /api/embeddings 或 OpenAI 的 /v1/embeddings)。

2.改造 KnowledgeService

修改保存逻辑和搜索逻辑,让它们自动调用 EmbeddingService。

java 复制代码
package com.example.esdemo.service;

import com.example.esdemo.document.KnowledgeDoc;
import com.example.esdemo.repository.KnowledgeRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Service
public class KnowledgeService {

    @Autowired
    private KnowledgeRepository knowledgeRepository;

    @Autowired
    private ElasticsearchOperations operations;

    @Autowired
    private EmbeddingService embeddingService; // 注入向量化服务

    // 【保存】自动生成向量
    public KnowledgeDoc save(String id, String content) {
        float[] embedding = embeddingService.embed(content);
        KnowledgeDoc doc = new KnowledgeDoc(id, content, embedding);
        return knowledgeRepository.save(doc);
    }

    public Optional<KnowledgeDoc> findById(String id) {
        return knowledgeRepository.findById(id);
    }

    public Iterable<KnowledgeDoc> findAll() {
        return knowledgeRepository.findAll();
    }

    // 【搜索】自动将问题转向量,再执行 kNN
    public List<KnowledgeDoc> search(String question, int k) {
        // 1. 文本转向量
        float[] queryVector = embeddingService.embed(question);

        // 2. 构建 kNN 查询
        List<Float> vectorList = toFloatList(queryVector);
        NativeQuery query = NativeQuery.builder()
                .withKnnSearches(knn -> knn
                        .field("embedding")
                        .queryVector(vectorList)
                        .k(k)
                        .numCandidates(Math.max(k * 10, 100))
                )
                .build();

        // 3. 执行搜索
        SearchHits<KnowledgeDoc> hits = operations.search(query, KnowledgeDoc.class);
        return hits.stream()
                .map(SearchHit::getContent)
                .toList();
    }

    // 辅助方法:float[] 转 List<Float>
    private List<Float> toFloatList(float[] array) {
        List<Float> list = new ArrayList<>();
        for (float f : array) {
            list.add(f);
        }
        return list;
    }
}

3.改造 KnowledgeController

对外暴露的接口只接收文本,不再接收向量。

typescript 复制代码
package com.example.esdemo.controller;

import com.example.esdemo.document.KnowledgeDoc;
import com.example.esdemo.service.KnowledgeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/knowledge")
public class KnowledgeController {

    @Autowired
    private KnowledgeService knowledgeService;

    // 保存:只传 id 和 content,后端自动生成向量
    @PostMapping
    public KnowledgeDoc save(@RequestBody SaveRequest request) {
        return knowledgeService.save(request.getId(), request.getContent());
    }

    @GetMapping("/{id}")
    public Optional<KnowledgeDoc> findById(@PathVariable String id) {
        return knowledgeService.findById(id);
    }

    @GetMapping
    public Iterable<KnowledgeDoc> findAll() {
        return knowledgeService.findAll();
    }

    // 搜索:只传人类语言问题,后端自动转向量并搜索
    @PostMapping("/search")
    public List<KnowledgeDoc> search(@RequestBody SearchRequest request) {
        return knowledgeService.search(request.getQuestion(), request.getK());
    }

    // ================= 请求体定义 =================

    public static class SaveRequest {
        private String id;
        private String content;

        public String getId() { return id; }
        public void setId(String id) { this.id = id; }

        public String getContent() { return content; }
        public void setContent(String content) { this.content = content; }
    }

    public static class SearchRequest {
        private String question;
        private int k = 3;

        public String getQuestion() { return question; }
        public void setQuestion(String question) { this.question = question; }

        public int getK() { return k; }
        public void setK(int k) { this.k = k; }
    }
}

4.测试

4.1.保存数据(只传文本)

bash 复制代码
POST http://localhost:8080/knowledge
{
  "id": "100",
  "content": "这是一款拍照很好的手机"
}

后端会自动调用 EmbeddingService 生成向量,并存进 ES。

4.2.搜索(只传问题)

bash 复制代码
POST http://localhost:8080/knowledge/search
{
  "question": "什么手机拍照好",
  "k": 3
}

后端会先把 "什么手机拍照好" 转成向量,再去 ES 里搜。

5.关于模拟 Embedding 的重要提醒

现在的 EmbeddingService 是假的,它生成的向量是随机的(基于 hashCode),没有任何语义。

这意味着:

  • 你搜"手机",可能匹配到"水果",因为向量是随机的。
  • 这只能验证代码流程是否正确,无法验证语义搜索效果。

要看到真正的语义搜索效果,必须替换成真实的 Embedding 模型。

6.其他疑问解答

6.1.k 参数:最多返回几条

6.2.为什么 kNN 是"Top-K"而不是"全部"?

因为向量搜索的本质是排序 ,不是过滤。

传统搜索:match "手机" → 命中所有包含"手机"的文档 → 全部返回。

向量搜索:给一个查询向量 → 计算它和所有文档向量的相似度 → 按相似度排序 → 返回前 K 个。

6.3.k 和 num_candidates 的区别

【此问题建议直接问AI详细解答】

代码里有两个参数,很多人会混淆:

less 复制代码
.withKnnSearches(knn -> knn
        .field("embedding")
        .queryVector(vectorList)
        .k(k)                                    // 最终返回几条
        .numCandidates(Math.max(k * 10, 100))    // 中间候选集大小
)
参数 含义 作用
k 最终返回的结果数 直接决定你看到几条
num_candidates 每个分片参与精算的候选数 影响准确率和性能

6.4.相似度有没有"阈值"?

【此问题建议直接问AI详细解答和拓展】

没有默认阈值。哪怕整个索引里最相似的文档相似度只有 0.01,只要你 k=3,它也会返回 3 条。

四、LLM 接入

1.在 pom.xml 中添加依赖

这一步很容易出问题。正确版本的选择总是个麻烦。而且普通的对话型AI也基本是乱说。我在此处是利用Agent帮我直接分析项目然后去改的pom.xml文件

以下是我自己项目中的pom文件完整内容:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.16</version>
        <relativePath/>
    </parent>
    <groupId>com</groupId>
    <artifactId>ElasticSearchDemo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ElasticSearchDemo</name>
    <description>ElasticSearchDemo</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>

    <properties>
        <java.version>17</java.version>
        <!-- Spring AI 官方版本(Spring AI Alibaba 1.1.2.1 基于此版本编译) -->
        <spring-ai.version>1.1.2</spring-ai.version>
        <!-- Spring AI Alibaba 版本号规则:<Spring AI 版本>.<阿里补丁号> -->
        <spring-ai-alibaba.version>1.1.2.1</spring-ai-alibaba.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!--
              只导入 Spring AI 官方 BOM。它的版本必须与 spring-ai-alibaba 依赖的版本一致,
              由它统一管理 spring-ai-commons / spring-ai-model / spring-ai-rag 等模块版本。
              注意:spring-ai-alibaba-extensions-bom 未发布到 Maven Central / 阿里云公共仓库,
              导入它会直接导致 "Non-resolvable import POM",因此这里不再使用它。
            -->
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <!--
          DashScope 通义大模型 + Embedding 向量。
          该 starter 不在上面的 BOM 管理范围内,必须显式声明 version。
        -->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
            <version>${spring-ai-alibaba.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--
              JDK 23 起 javac 不再自动执行类路径上的注解处理器,Lombok 会静默失效,
              表现为 "@RequiredArgsConstructor 的 final 字段未初始化" 这类编译错误。
              显式声明 annotationProcessorPaths 可同时启用注解处理并锁定 Lombok 版本。
              lombok.version 由 spring-boot-dependencies 统一管理。
            -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${lombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

2.配置 application.yml

yaml 复制代码
server:
  port: 8080

spring:
  application:
    name: es-demo
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 5s
    socket-timeout: 30s
  ai:
    dashscope:
      api-key: sk-你的真实APIKey
      embedding:
        options:
          model: qwen3.7-text-embedding # 这里选择控制台里显示"已开启"且有剩余额度的【向量】模型

操作指引:

  1. 去阿里云百炼控制台(bailian.console.aliyun.com/)登录。

    还得开启模型。对新用户阿里云百炼有很多免费模型可以用。

  2. 左侧菜单找 API-KEY 管理,创建一个新 Key。

  3. 把 sk-你的真实APIKey 替换成实际值。

    我是让项目通过电脑操作系统环境变量的方式获取API_KEY

提醒 :text-embedding-v3 默认输出 1024 维,这是下一步重建索引的依据。

3.在 Kibana 中重建索引(3 维 → 1024 维)

打开 Kibana DevTools,依次执行:

json 复制代码
// 1. 删掉旧的 3 维假索引
DELETE knowledge

// 2. 重建为 1024 维
PUT knowledge
{
  "mappings": {
    "properties": {
      "content": { "type": "text", "analyzer": "ik_max_word" },
      "embedding": {
        "type": "dense_vector",
        "dims": 1024,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

执行后验证一下:

bash 复制代码
GET knowledge/_mapping

确认 dims 是 1024。

4.修改实体类 KnowledgeDoc.java

把原来的 dims = 3 改为 dims = 1024:

ini 复制代码
@Field(type = FieldType.Dense_Vector, dims = 1024)
private float[] embedding;

其他字段不用动。

5.改造 EmbeddingService.java

把之前的假实现整个替换掉:

arduino 复制代码
package com.example.esdemo.service;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;

@Service
public class EmbeddingService {

    private final EmbeddingModel embeddingModel;

    // 构造器注入,Spring AI Alibaba 会自动配置 DashScope 的 EmbeddingModel
    public EmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    /**
     * 调用真实的 DashScope text-embedding-v3 模型,将文本转为 1024 维向量
     */
    public float[] embed(String text) {
        return embeddingModel.embed(text);
    }
}

6.启动并验证真实语义

6.1.启动 Spring Boot

6.2.用 Postman 保存真实数据

bash 复制代码
POST http://localhost:8080/knowledge
Content-Type: application/json

{"id":"1","content":"iPhone 15 Pro 搭载 A17 Pro 芯片,拍照效果极其出色,适合摄影爱好者"}
json 复制代码
{"id":"2","content":"香蕉是一种富含钾元素的热带水果,有助于消化"}
json 复制代码
{"id":"3","content":"小米 14 Ultra 配备徕卡光学镜头,在暗光环境下依然能拍出清晰的照片"}

保存成功后,返回的 embedding 数组长度应该是 1024,而不是之前的 3。

6.3.用 Postman 做语义搜索

bash 复制代码
POST http://localhost:8080/knowledge/search
Content-Type: application/json

{"question":"我想买个拍照好的手机","k":2}

预期结果:

  • id=1(iPhone)和 id=3(小米)排在前面
  • id=2(香蕉)不在结果里或排最后

如果符合预期,说明真实语义搜索彻底跑通。

7.验证 ES 中真实存储的向量

在 Kibana DevTools 里执行:

bash 复制代码
GET knowledge/_search
{
  "query": { "match_all": {} },
  "_source": ["content"],
  "size": 10
}

应该看到三条正常的中文文本内容。

想看一眼向量长什么样:

bash 复制代码
GET knowledge/_doc/1

会看到 embedding 是个 1024 个浮点数的数组。

8.运行成功

🎉 完美!大功告成!你已经成功跑通了真实的 Embedding 链路!

9.乘胜追击

9.1.再存几条不同的数据(测试对比)

bash 复制代码
POST http://localhost:8080/knowledge
Body (JSON):
{"id":"2","content":"香蕉是一种富含钾元素的热带水果,有助于消化"}

POST http://localhost:8080/knowledge
Body (JSON):
{"id":"3","content":"小米 14 Ultra 配备徕卡光学镜头,在暗光环境下依然能拍出清晰的照片"}

POST http://localhost:8080/knowledge
Body (JSON):
{"id":"4","content":"老虎是大型猫科动物,主要分布在亚洲的森林中"}

9.2.进行语义搜索测试

bash 复制代码
POST http://localhost:8080/knowledge/search
Body (JSON):
{"question": "我想买个拍照好的手机", "k": 2}

预期结果 : 返回的列表里,应该包含 id=1 (iPhone) 和 id=3 (小米),而 id=2 (香蕉) 和 id=4 (老虎) 应该排不进来。

🎉 太棒了!恭喜你,你已经彻底打通了 RAG 中最难、最核心的"检索(Retrieval)"环节!

10.Generation(生成)

10.1.创建 RagService 层

Spring AI Alibaba 已经自动配置好了 ChatClient.Builder,我们直接注入即可。

java 复制代码
package com.elasticsearchdemo.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class RagService {

    private final KnowledgeService knowledgeService;
    private final ChatClient chatClient;

    // 构造器注入
    public RagService(KnowledgeService knowledgeService, ChatClient.Builder chatClientBuilder) {
        this.knowledgeService = knowledgeService;
        // 构建 ChatClient,模型可以在 application.yml 里配置,这里先使用默认配置
        this.chatClient = chatClientBuilder.build();
    }

    /**
     * RAG 核心流程:检索 + 增强 + 生成
     */
    public String ask(String question) {
        // 1. 检索(Retrieval):通过刚才的语义搜索,取最相关的 3 条
        List<KnowledgeDoc> docs = knowledgeService.search(question, 3);

        if (docs.isEmpty()) {
            return "抱歉,知识库中暂无相关参考资料,我无法回答该问题。";
        }

        // 2. 增强(Augmented):把检索到的文档内容拼接成上下文
        String context = docs.stream()
                .map(KnowledgeDoc::getContent)
                .collect(Collectors.joining("\n---\n"));

        // 3. 生成(Generation):构造 Prompt 并调用大模型
        String prompt = """
                请严格根据以下参考资料回答用户的问题。如果参考资料中没有答案,请直接回答"我不知道"。
                
                【参考资料】:
                %s
                
                【用户问题】:
                %s
                """.formatted(context, question);

        // 调用大模型(例如通义千问 qwen-plus),返回回答
        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }
}

10.2.在 KnowledgeController 中增加 RAG 问答接口

打开 KnowledgeController.java,注入刚才写的 RagService,并增加一个 /ask 接口。

less 复制代码
// 注入 RagService
@Autowired
private RagService ragService;

// ... 原有的 save 和 search 接口保持不变 ...

// 新增 RAG 问答接口
@PostMapping("/ask")
public String ask(@RequestBody SearchRequest request) {
    return ragService.ask(request.getQuestion());
}

10.3.配置对话模型

对话模型和向量模型是分开计费的,之前在yaml配置文件中只配置了qwen3.7-text-embedding 这个向量模型。

在自己的阿里云百炼平台上选择一个"已开启"且有剩余额度的【大语言】模型或【多模态】模型。

我此处最后用的是qwen3.8-max这个模型。

我学习时遇到的问题:

问题演进过程:

  1. 初始日志显示 DashScopeEmbeddingModel 返回 HTTP 404。
  2. 根因是 base-url 配置成了 https://dashscope.aliyuncs.com/compatible-mode/v1,但 spring-ai-alibaba-starter-dashscope 使用原生协议,会自动追加 /api/v1/services/embeddings/text-embedding/text-embedding,最终拼出不存在的地址。
  3. 修正为原生地址后,404 消失,改成 HTTP 403 AllocationQuota.FreeTierOnly,说明端点已经正确,但 Embedding 账号额度或模型权限不足。
  4. 你后来把 Embedding 模型换成了当前账号可用的 qwen3.7-text-embedding,Embedding 阶段通过,检索也成功。
  5. 之后错误转移到 Chat 阶段,DashScopeApi.chatCompletionEntity 返回 HTTP 400 InvalidParameter: url error。
  6. 根因是 qwen3.8-max 需要通过 DashScope 多模态生成端点调用。
  7. 增加 multi-model: true 后,SDK 改走 /api/v1/services/aigc/multimodal-generation/generation,接口验证成功。

说明:

  • base-url 使用 DashScope 原生地址,不能配置 compatible-mode/v1。
  • qwen3.7-text-embedding 是当前账号实际可用的 Embedding 模型。
  • multi-model: true 用于让 qwen3.8-max 走多模态生成端点。

10.4.其他冷知识:

为什么DeepSeek的模型出现在了dashscope阿里云平台上?

阿里云百炼(DashScope)现在是一个"模型超市",而不仅仅是一个"通义千问专属店"。

10.5.用 Postman 发起 RAG 提问

bash 复制代码
POST http://localhost:8080/knowledge/ask
Body (JSON):
{
  "question": "我想买个拍照好的手机,有什么推荐?"
}

预期结果

你会得到一段由大模型生成的、通顺的回答,例如:

根据参考资料,我为您推荐以下两款手机:

  1. iPhone 15 Pro:搭载 A17 Pro 芯片,拍照效果极其出色,适合摄影爱好者。
  2. 小米 14 Ultra:配备徕卡光学镜头,在暗光环境下依然能拍出清晰的照片。 这两款手机在摄影方面都有很强的表现,您可以根据自己的系统和品牌偏好进行选择。

10.6.🎉 恭喜恭喜!RAG 全链路终于跑通了!

RAG 最小闭环已经跑通

10.7.问题总结

11.其他知识点

11.1.流式输出

希望AI返回的是带有md格式的文本数据,同时又要流式返回前端,流式到达的 Markdown 片段是"未闭合"的,前端直接渲染会乱。

方案一:前端累积 + 节流渲染(最简单,推荐)

方案二:用流式 Markdown 专用库(体验最好)

11.2.目前是否还未用到向量数据库?

其实已经用到了"向量数据库"的核心功能,只不过载体是 Elasticsearch,而不是一个专门的向量数据库。

还没有用"专用的向量数据库(Milvus/Qdrant 等)",但你已经用上了"向量数据库的能力"。

11.3.Milvus占用存储空间多少?

Milvus 的存储占用不是一个固定值,它由基础部署资源 和随数据量增长的动态资源 两部分组成。最小化单机部署建议准备至少 8GB 内存和 50GB 磁盘,但实际内存和磁盘占用主要取决于你的向量数据规模和所选的索引类型。

50GB 是一个"建议预留"的磁盘空间,并不是 Milvus 一安装就会立刻占满 50GB。

11.4.MCP是否只是个协议而已?

在一些面试时被问到怎么使用MCP,感觉问法有问题。

在面试中被问"怎么使用MCP"之所以感觉别扭,是因为这个问题本身问得太宽泛、太不专业了。

简单回答你的疑问:MCP(Model Context Protocol)本质上确实只是一个协议(标准),但围绕它已经形成了一套完整的"客户端-服务端"生态。

MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的一项开源标准。它的核心作用是标准化 AI 模型(如 Claude、GPT、通义千问)与外部数据源、工具之间的连接方式。

MCP 就是规定了 "AI 客户端(如 Claude Desktop、IDEA、Cursor)" 和 "工具服务端(如你的数据库、GitHub、本地文件系统)" 之间怎么用 JSON-RPC 2.0 进行通信。

相关推荐
SamDeepThinking3 小时前
synchronized 和 lock 有什么区别?
java·后端·面试
m0_587383003 小时前
无人自助拼豆系统的全栈开发方案
java·后端·struts·小程序·架构
临期冰淇淋3 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
人道领域3 小时前
【面试八股速通篇01】锁与并发工具
java·开发语言·面试·c#
Wang's Blog3 小时前
Java 项目实战: 交友项目 - Dubbo 入门案例全流程复盘
java·dubbo·交友
小七在进步4 小时前
初识C++中的模板
java·开发语言·c++
Wang's Blog4 小时前
Java 项目实战: 交友项目 - SpringCloud 整合 Dubbo 的需求分析与模块规划
java·spring cloud·交友
哦虎!4 小时前
【JavaEE】文件操作IO
java
十年Java程序媛4 小时前
Redis 分布式锁 + GZIP 压缩缓存踩坑|高并发缓存真实线上复盘
java·开发语言·spring boot·redis·分布式·缓存·面试