☰
Redis 8 向量检索实战:从语义搜索到 Spring Boot 集成
2026/10/9 4:04:11 网站建设 项目流程

最近接了个需求:电商后台想做一个“找相似商品”的功能,用户搜“轻便适合通勤的无线耳机”,但商品的标题只有“蓝牙耳机 低延迟 入耳式”这种词,传统的关键词搜索完全匹配不上。这种问题其实就是典型的语义检索场景,常规做法是引入向量数据库。但我们团队当时不想为了一个搜索功能再维护一套独立中间件,于是我把目光放到了已经在生产的 Redis 8 上。

Redis 8 的向量检索能力(基于 RediSearch 模块的向量索引)可以让后端团队用非常低的成本,在一个已经熟悉的组件里完成语义搜索的落地。文章后面我会用完整的命令拆解和 Spring Boot 集成代码,演示从零构建一个可用的智能搜索系统。不管你是刚开始接触向量的新手,还是已经用过 Milvus、pgvector 这类专用数据库的老手,这篇文章都能帮你快速找到在 Redis 上做向量检索的最佳姿势。

1. 为什么我把语义搜索方案选到 Redis 8 上

1.1 传统关键词搜索的“天花板”到底在哪里

传统搜索的核心是倒排索引。系统把文本拆成词,再根据词去召回文档。这个方案在“精准词匹配”场景下非常稳,比如搜“iPhone 15 保护壳”,只要商品标题里有这个词就能命中。

但语义搜索的核心问题是:用户的输入和商品描述往往是两套词汇体系。“通勤”对应商品标题里的“便携”,“送礼”对应“精美包装”,“降噪”对应“主动降噪”。这些关系靠同义词表能解决一部分,但无穷尽,而且维护成本极高。

我之前在一个内容平台做过一次搜索体验优化,当时运营维护了上千条同义词规则,用户搜索“如何入门摄影”还是无法召回标题为“零基础单反相机学习指南”的文章。后来我们才意识到,问题不是分词不够好,而是索引模型本身没有理解语义的能力,需要从文本表征层面换一种思路。

1.2 把文本变成高维空间里的坐标

语义搜索的基础思路,是用神经网络把文本映射成一个高维向量。这个过程和人脸识别的逻辑完全一致:人脸图像输入卷积神经网络,经历多层特征提取,最后输出一个如 512 维或 1024 维的向量。这个向量并不是人眼能看懂的坐标,但它在高维空间里表达了“这张脸长什么样”。

文本也是一样。把“轻便适合通勤的无线耳机”和“蓝牙耳机 低延迟 入耳式”分别向量化之后,两句话虽然没有一个字相同,但向量的空间距离会非常近,因为它们描述的是同一个物体。搜索引擎要做的事情就变成了:给定一个查询向量,在向量空间里找出离它最近的 K 个商品向量。

1.3 Redis 8 在向量检索生态里的定位

现在市面上的向量检索方案不少,我整理了一张对比表,方便直观理解 Redis 的定位。

方案核心优势主要代价适合场景
Redis 8 + RediSearch运维简单,复用已有 Redis,支持向量和结构化过滤组合查询全内存,海量向量成本高百万级以内,中小规模业务搜索
Milvus分布式部署,十亿级向量能力,支持混合查询组件多,部署维护较重大规模 AI 应用、推荐系统
pgvector数据在 PostgreSQL 中,酸性事务与向量检索统一性能上限受限于 PG 单机,调优复杂已有 PG 体系,向量量级不大
Elasticsearch倒排搜索成熟,向量插件能力也不错资源开销大,配置复杂文本搜索和向量混合检索的大型平台

图数据库和向量数据库是两类不同的问题,图数据库处理多跳关系查询,向量数据库处理“看起来像什么”的相似度计算,在智能搜索场景里两者更多是互补,而不是替代。

1.4 选型时的三个取舍理由和一条边界

我选 Redis 8 有很实际的三个理由。第一,运维成本为零。公司核心缓存本来就是 Redis,不用再申请新的 Milvus 集群,也不用单独维护索引重建任务。第二,生态复用。Redis 里已有的 String、Hash、TTL、分布式锁等能力直接可用,向量索引跟业务缓存混布在一个集群里,少了一个中间件就少了一份告警。第三,组合过滤能力强。向量搜索往往不是纯粹的“按相似度排序”,还要过滤“商品状态=上架”“分类=数码”这种条件,Redis 的 FT.SEARCH 原生支持向量 KNN 与结构化条件组合,这个在刚起步的项目里非常省事。

边界也很清晰:Redis 是内存系统,如果单节点向量数据量超过千万级,内存成本和索引重建压力会很大,这种规模还是老老实实用分布式向量数据库。对大多数 Web 项目来说,几千到几百万条向量完全是 Redis 的舒适区。

2. 向量化这一步别搞错:模型、维度与距离度量

2.1 嵌入模型怎么选:本地部署还是调用外部 API

搜索系统的入口是向量化服务。简单说,你选一个能把文本变成向量的模型,跑完以后拿到一串 float 数组,再交给 Redis 存起来。

中文场景我个人的首选是 BGE 系列,比如 bge-m3,它对中文的理解能力很好,输出 1024 维向量,支持中英双语和长文本,而且权重可以本地加载,用 ONNX Runtime 或 Python 后端起一个 HTTP 服务就行。另一个常见选择是 m3e-base,输出 768 维,模型更小,部署更容易。如果团队没有 GPU,本地 CPU 跑这些模型做离线批量向量化完全没问题,在线单条查询也基本在几十毫秒量级。

外部 API 方案比如 OpenAI 的 text-embedding-3-small,效果很好而且省心,但需要考虑数据出域和调用成本。我之前踩过一个小坑:某个内部知识库的文档不能出内网,最后用了本地 bge 模型。这里的建议是,业务数据敏感、调用量大就优先本地模型,数据不敏感、团队不想维护模型服务就调 API,两者在 Redis 侧的使用方式完全一样。

2.2 三种距离度量:余弦、欧氏、内积

向量检索的核心是比较两个向量的相似度。Redis 8 支持三种距离度量,它们对应不同的几何含义。

  • 余弦相似度(COSINE):看两个向量的方向是否一致。几何上就是向量点积除以两个向量模长的乘积,夹角越小越相似。余弦距离 = 1 - 余弦相似度。
  • 欧氏距离(L2):看两个向量在空间里的直线距离。点积有个直观的几何解释:两个向量夹角为锐角时点积为正,钝角时点积为负,正交时为零;而向量的模长就是范数。
  • 内积(IP):直接计算向量点积。如果所有向量都做了归一化,内积结果和余弦相似度等价。

前几年算法课上学向量点积、夹角、范数,只看公式觉得抽象,后来做语义搜索才真正体会到这些概念的实战意义:余弦相似度对文本长度不敏感,所以文本检索我基本固定用 COSINE;如果向量做了 L2 归一化,用 IP 反而速度更快,原理是一样的,但 Redis 内部计算路径不同。

2.3 归一化:很多入门项目翻车的起点

向量归一化是个容易被忽略但影响很大的操作。很多模型输出的向量并不是标准长度,直接比较余弦距离理论上也能工作,但实际工程里我强烈建议先做 L2 归一化再存储。

做过一次实验,同一批商品数据,未归一化写入和归一化后的检索结果在 Top 10 里有接近一半不一致。原因是未归一化的向量,模长本身携带了“文本信息量”的噪音,比如更长的文档往往向量模长更大,导致距离被长度干扰。归一化以后,模长变成 1,距离只反映语义方向,结果稳定得多。

2.4 嵌入维度必须前后一致

不同模型输出的向量维度差异很大:bge-m3 是 1024 维,m3e-base 是 768 维,OpenAI 的 text-embedding-3-large 是 3072 维。这个维度不仅是模型参数,更直接决定 Redis 索引创建时 DIM 的配置。

索引的 DIM 必须和模型输出维度一致,类型也需要保持一致,Redis 索引里用 FLOAT32 就统一用 FLOAT32。如果模型换版本导致维度变了,必须重建索引并重新向量化全量数据。这些看似简单的问题,在实际项目里经常成为第一天跑不通的最大原因。

3. Redis 8 向量索引命令拆解:从建索引到 KNN 查询

3.1 环境准备:安装 Redis 8 并确认模块能力

Redis 8 可以通过 Docker、源码编译或系统包管理安装。Docker 是最快的方式:

docker run --name redis8 -p 6379:6379 -d redis:8

安装完成后,先验证当前 Redis 是否具备搜索和向量能力。可以用 MODULE LIST 查看已加载模块,或者直接执行 FT.INFO 命令测试某个索引是否存在。如果提示未知命令,说明 RediSearch 模块没有加载。基于 Redis Stack 的镜像一般直接包含搜索模块,生产环境建议用 redis/redis-stack-server 镜像,向量能力开箱即用。

3.2 FT.CREATE:创建向量索引的语法拆解

假设商品信息存在 Hash 结构里,key 为 product:1、product:2,字段有 title、category、embedding。创建一个支持向量检索的索引命令如下:

FT.CREATE idx:product ON HASH PREFIX 1 product: SCHEMA title TEXT WEIGHT 5.0 category TAG embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 1024 DISTANCE_METRIC COSINE

逐个解释关键部分。ON HASH PREFIX 1 product: 表示扫描并索引以 product: 为前缀的 Hash 键。title 字段声明为 TEXT,支持关键字文本过滤,权重设为 5.0 表示这个字段在混合检索里的文本权重更高。category 声明为 TAG,用于精确匹配过滤,比如“只查数码类目”。embedding 字段声明为 VECTOR 类型,算法用 HNSW,后面跟 6 个参数:TYPE FLOAT32、DIM 1024、DISTANCE_METRIC COSINE。

这里 FLAT 和 HNSW 的选择值得多说几句。FLAT 是暴力扫描索引,查询时把向量和全量数据逐条计算,准确率最高,但数据量上去后性能线性下降,适合万级以下的小集合。HNSW 是分层可导航小世界图索引,构建时建立多层图结构,查询时从高层跳转到低层,搜索速度快得多,但会占用额外内存,且在插入数据时有一定概率牺牲精度。我对中小规模项目默认用 HNSW,参数按官方推荐初始值 M=16、EF_CONSTRUCTION=200、EF_RUNTIME=10 起步。

3.3 HSET 写入向量数据:二进制序列化是关键

向量数据必须写成 float32 数组的字节流,而不是 JSON 或逗号分隔的字符串。早期文档里有人把向量转成 CSV 字符串存进 Redis,FT.CREATE 时索引可以创建,但查询时返回全空,问题就出在这里。

以 1024 维向量为例,正确写入方式是把 1024 个 float 按小端序写入字节数组,共 4096 字节。命令行写入不方便,建议用客户端或脚本写入。Python 示例如下:

import redis import struct r = redis.Redis(host='127.0.0.1', port=6379) vector = [0.1] * 1024 # 假设模型输出的向量 blob = struct.pack('<%df' % len(vector), *vector) r.hset('product:1', mapping={ 'title': '轻便无线蓝牙耳机', 'category': '数码', 'embedding': blob })

如果你想知道数据写入对不对,可以用 HSET 把字段读出来,检查长度是否为 1024*4 字节,或者直接用 FT.INFO 查看索引里文档数量是否增加。

3.4 FT.SEARCH 查询:KNN 语法与参数

向量查询的核心语法是 KNN 过滤器。下面这个命令查询和给定向量最相似的 10 条商品:

FT.SEARCH idx:product "*=>[KNN 10 @embedding $query_vec AS dist]" \ PARAMS 2 query_vec "\x00\x01..." \ DIALECT 4 \ SORTBY dist \ RETURN 3 title category dist

逐个拆解。*=>[KNN 10 @embedding $query_vec AS dist] 表示在全量数据上做 KNN 搜索,返回 10 条,@embedding 是向量字段名,$query_vec 是参数占位符,AS dist 将距离值作为一个虚拟字段输出。PARAMS 2 query_vec <字节串> 用于传参。DIALECT 4 是查询语法版本,写库和查询都要用支持向量能力的最新版本。SORTBY dist 让结果按距离升序排列,距离越小越相似。RETURN 指定返回的字段。

注意:dist 的含义取决于度量方式。COSINE 返回的是 1 减去余弦相似度,所以 dist=0 表示完全相同,dist 接近 1 表示基本不相关。实际项目里通常设一个阈值,比如 dist < 0.35 才认为是有效结果,避免查询条件过宽导致返回一堆无关数据。

3.5 先跑通一个最小验证用例

拿到上面的命令,我建议先别写代码,直接用命令行验证一遍。往 Redis 里写入 5 条商品 Hash 数据,给每一条生成一个向量,其中两条语义上类似,然后发起 FT.SEARCH 查询。这一步能验证模型输出、向量编码、索引配置、查询语法四条链路是否通顺,之后再做工程封装。

这个过程跑通以后,你会对向量检索有一个非常直觉的认识:建立索引结构、写入向量、查询最近邻,三步走,背后没有黑魔法。

4. 用 Spring Boot 落地一个智能搜索接口

4.1 整体链路设计

从零搭建搜索接口,链路可以拆成四段。

  1. 离线索引构建:从数据库读取商品数据,调用本地向量模型服务生成 embedding,然后写入 Redis Hash。
  2. 在线查询:用户输入查询词,先向量化,再调用 Redis FT.SEARCH。
  3. 结果业务化:把 Redis 返回的商品 ID 和距离信息组装成完整商品信息。
  4. 缓存治理:对高频查询结果缓存到 Redis String,减少重复向量计算和查询压力。

如果项目里已经接了若依这种后台框架,用它的定时任务组件做增量数据同步就很方便,每五分钟把新上架的商品向量化一次。这里不建议实时向量化,因为每次写入都要调模型,在高并发下容易把模型服务和 Redis 打满。

4.2 工程依赖与基础配置

使用 Spring Boot 3 + Spring Data Redis + Jedis 客户端。关键 pom.xml 依赖如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>

Spring Data Redis 的 RedisConnection.execute 方法可以直接发送 FT.CREATE、FT.SEARCH 这类原始命令,不需要额外引入 Redisearch 客户端,兼容性最好。

4.3 向量写入的核心代码

RedisTemplate 的默认序列化可能会干扰 Hash 里二进制字节流的读写,所以这里直接用 StringRedisTemplate 的底层连接操作。

@Service public class ProductEmbeddingService { private final StringRedisTemplate stringRedisTemplate; public ProductEmbeddingService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public void saveProductEmbedding(String productId, String title, String category, float[] vector) { byte[] embedding = new byte[vector.length * 4]; ByteBuffer.wrap(embedding) .order(ByteOrder.LITTLE_ENDIAN) .asFloatBuffer() .put(vector); stringRedisTemplate.execute((RedisCallback<Object>) connection -> { byte[] key = ("product:" + productId).getBytes(StandardCharsets.UTF_8); connection.hSet(key, "title".getBytes(StandardCharsets.UTF_8), title.getBytes(StandardCharsets.UTF_8)); connection.hSet(key, "category".getBytes(StandardCharsets.UTF_8), category.getBytes(StandardCharsets.UTF_8)); connection.hSet(key, "embedding".getBytes(StandardCharsets.UTF_8), embedding); return null; }); } }

这段代码做了三件事:把 float 数组转成小端字节序、写入 title 和 category 结构化字段、写入 embedding 二进制向量。注意维度变化时必须同步修改数组长度,否则字节长度和索引 DIM 不匹配。

初始化索引的操作独立放到一个初始化方法里,启动任务时执行一次。这里用 FT.CREATE 的 IF NOT EXISTS 防止重复创建报错。

4.4 查询接口与排序逻辑

查询接口接收用户输入,先调用模型服务得到查询向量,再拼 KNN 查询命令。核心代码:

public List<SearchResult> search(String queryText, int topK) { float[] queryVector = vectorModelService.embed(queryText); byte[] queryBlob = floatArrayToBytes(queryVector); List<Object> rawResult = stringRedisTemplate.execute((RedisCallback<List<Object>>) connection -> (List<Object>) connection.execute("FT.SEARCH", "idx:product".getBytes(StandardCharsets.UTF_8), "*=>[KNN 10 @embedding $query_vec AS dist]".getBytes(StandardCharsets.UTF_8), "PARAMS".getBytes(StandardCharsets.UTF_8), "2".getBytes(StandardCharsets.UTF_8), "query_vec".getBytes(StandardCharsets.UTF_8), queryBlob, "DIALECT".getBytes(StandardCharsets.UTF_8), "4".getBytes(StandardCharsets.UTF_8), "SORTBY".getBytes(StandardCharsets.UTF_8), "dist".getBytes(StandardCharsets.UTF_8), "LIMIT".getBytes(StandardCharsets.UTF_8), "0".getBytes(StandardCharsets.UTF_8), String.valueOf(topK).getBytes(StandardCharsets.UTF_8), "RETURN".getBytes(StandardCharsets.UTF_8), "3".getBytes(StandardCharsets.UTF_8), "title".getBytes(StandardCharsets.UTF_8), "category".getBytes(StandardCharsets.UTF_8), "dist".getBytes(StandardCharsets.UTF_8))); // 解析 rawResult,结构类似于 [文档数, "product:1", ["title", "xxx", "category", "数码", "dist", "0.12"], ...] return parseSearchResult(rawResult); }

返回值解析需要单独写一个方法。FT.SEARCH 的返回结构是平铺的列表,先是一个总数,然后是每条文档的 key 和字段键值对。解析时建议以 dist 字段为准做排序,并过滤掉 dist 过大的结果,我常用的阈值是 0.35,超过说明语义相关度太低,直接返回空列表比返回一堆无关结果更合理。

4.5 查询结果的缓存治理

向量检索虽然已经很快,但每次查询都要序列化查询向量、执行 KNN 计算、解析结果。对高频搜索词,一个简单有效的优化是加缓存。

把“查询词 -> 商品 ID 列表”缓存到 Redis String 中,TTL 设为 600 秒,key 可以用查询词的哈希值。这样做能显著降低查询接口的延时和 Redis CPU 消耗。要注意的是,如果商品数据频繁变化,缓存会造成一定的延迟可见性,需要根据业务容忍度调整 TTL 或主动清理。这一步做下来,搜索接口的 P99 延时基本可以稳定在 10ms 以内。

5. 踩坑实录:维度不一致、HNSW 参数与持久化那些事

5.1 维度不匹配时的怪异表现及排查链路

建索引、写向量、查 KNN,看起来都对,但搜索结果总是空的。这个坑我至少见过三次,每次都是同学把索引 DIM 设成了 512,而模型实际输出 768 维。

排查链路很重要,别一上来就怀疑 Redis 版本。第一步执行 FT.INFO idx:product,查看 index_definition 中的 schema 定义,确认 DIM 实际是多少。第二步用 HSET 读取一条数据,检查 embedding 字段的字节长度,DIM 为 768 时字节数应该是 3072,如果是 4096 就说明模型输出是 1024 维,和索引 DIM 不匹配。第三步检查字节序,模型输出如果是 numpy 数组默认大端序,而 RediSearch 要求小端序,也会导致查询不出结果。维度不匹配通常表现为写入成功但查询空,字节序错误通常表现为查询报错或结果混乱,两者要区分开。

5.2 HNSW 参数到底调多大

HNSW 有几个参数直接影响索引质量和查询性能。

参数含义建议初始值调大影响
M每个节点的最大连接数16内存增加,检索精度提高,构建变慢
EF_CONSTRUCTION构建时动态候选列表大小200索引质量更高,构建更慢
EF_RUNTIME查询时扩展候选列表大小10查询召回率提高,但查询变慢

如果业务对召回率特别敏感,比如“相似商品”推荐场景,EF_RUNTIME 可以调到 40;如果追求查询极速,EF_RUNTIME=10 再配合缓存层就够。不要太贪心,曾经有个项目为了精度把 M 调到 64,索引占用内存直接翻了接近一倍,查询速度却没有明显提升,最后改回 16 并配合结果过滤,效果更好。

5.3 向量数据在 RDB 和 AOF 下的持久化行为

Redis 的持久化机制在向量场景下特别值得关注。默认 RDB 快照会把所有 Hash 数据写入 dump.rdb,向量是二进制大对象,所以内存里 1GB 的向量数据在持久化时可能会有明显耗时,对阻塞风险较高的业务要做好保护。

我曾经在一个生产环境遇到过一个问题:AOF 开启时,如果杀进程恢复,Redis 重启后需要把 AOF 里的 HSET 命令全部重放,向量数据每次写入都是 4096 字节的二进制命令流,累积起来 AOF 文件膨胀很快,恢复时间也会拉长。这里建议开启 AOF 的自动重写,并把向量写入做成批量提交,而不是逐条 HSET。另外,向量索引本身是构建在 Hash 数据之上的,Redis 重启后索引不会自动重建,需要在启动流程里先 FT.CREATE IF NOT EXISTS,再等待数据加载完成。如果数据是从持久化恢复的,索引构建会自动基于已有数据发生,但一定要先建索引再写数据,否则部分旧数据可能得不到索引。

5.4 混合检索:向量相似度加业务过滤

现实中很少有人只按向量距离排序。一个典型的查询是“数码分类下和‘降噪耳机’最相似的商品”。

FT.SEARCH idx:product "@category:{数码}=>[KNN 10 @embedding $query_vec AS dist]" \ PARAMS 2 query_vec "\x00..." \ DIALECT 4 SORTBY dist

这段命令先把结果限定在 category=数码,再做 KNN 搜索。TAG 字段用 {数码} 精确匹配,TEXT 字段可以用 @title:xxx 做关键词过滤。向量搜索和传统过滤组合使用,效果立刻就从“相似度排序”变成了“业务可控的智能搜索”。

5.5 查询超时与线程模型的影响

Redis 是单线程执行命令模型,一条 KNN 查询如果向量维度高、数据集大,会占住 CPU 很长时间。我的经验是,对于百万级以下的数据集,单条查询基本在几十毫秒内完成,不必过度担心。但如果你把 EF_RUNTIME 调到很大,又在一个 8GB 数据的大索引上做暴力查询,就很容易出现慢命令,影响同一 Redis 实例上的其他业务。

监控手段也很简单:用 INFO COMMANDSTATS 查看 FT.SEARCH 的调用频率和耗时,同时密切关注 Redis 实例的内存指标,因为 HNSW 索引的内存开销通常比原始向量多 20%-50%。另外,Redis 8 如果和普通业务缓存混布,建议给搜索相关的 key 打上统一前缀,未来万一向量搜索量太大,可以通过前缀快速拆分到独立 Redis 实例。

5.6 向量搜索上线前的自检清单

根据我自己的项目经验,整理了一份上线前必查清单:

  • 索引 DIM 和模型输出维度、字节序都一致吗?
  • 向量写入前做 L2 归一化了吗?
  • 查询 DIALECT 版本和索引支持版本匹配吗?
  • 高频查询的缓存 TTL 设置合理吗?
  • 重启后索引会通过 FT.CREATE IF NOT EXISTS 自动检查吗?
  • 超过了阈值(比如 dist > 0.35)的查询结果有兜底逻辑吗?
  • 向量模型服务挂了之后,搜索接口是降级为空结果还是会 500?

这些问题每一条都在实际项目中出过状况。尤其是最后一条,向量化服务一旦故障,整个搜索接口直接崩溃的情况并不少见,最好在调用模型服务时做超时保护,超时后降级到传统关键词搜索。

最后再说一点个人体会。Redis 8 的向量检索并不是要取代 Milvus 这类专用系统,它真正解决了多数后端团队的“第一公里”:当你还不需要处理亿级数据、不想引入新中间件、只想快速上线一个语义搜索功能的时候,Redis 8 是目前我见过的摩擦最小的方案。整个过程从命令验证到 Spring Boot 集成,一天就能跑通。如果你也正在评估类似需求,建议先拿一批真实数据跑通链路,再用 FT.INFO 观察索引的内存占用和查询耗时,再决定要不要扩大规模。小技巧:向量入库前尽量做归一化,查询时一定记得设距离阈值,这两点做好了,项目的搜索体验大概率不会差。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询