前言
大模型时代,检索增强生成(RAG)、智能推荐、多模态检索等场景已成为业务创新的核心方向,而向量数据库正是支撑这些场景的底层基石。很多开发者提起向量数据库,第一反应是Milvus、Pinecone这类专业组件,却忽略了日常高频使用的Redis,已经具备了完整的企业级向量数据库能力。无需额外部署新组件,无需重构现有技术栈,就能基于Redis快速搭建低延迟、高可用的向量检索服务。
一、Redis向量数据库核心认知
1.1 核心定义
Redis向量数据库的能力,由Redis官方的RediSearch模块提供核心支撑,Redis Stack默认集成了该模块与RedisJSON等扩展组件。它在Redis原生的高性能KV存储能力之上,扩展了稠密向量的索引构建、存储、相似度检索与混合查询能力,能够将文本、图片、音频、用户画像等非结构化数据转换的向量,进行高效的近邻检索。
1.2 核心能力边界
Redis向量数据库的核心能力覆盖以下维度:
- 支持FLOAT32类型的稠密向量,最大支持32768维向量
- 提供两种核心索引类型:FLAT精确索引与HNSW近似索引
- 支持3种主流相似度度量算法:L2欧几里得距离、内积(IP)、余弦相似度
- 支持向量检索与标签、数值、文本关键词的混合过滤查询
- 完全兼容Redis原生的主从、哨兵、集群高可用架构
- 兼容Redis原生的持久化机制(RDB/AOF)、事务、过期策略等所有能力
1.3 选型对比:Redis向量库 vs 专业向量数据库
| 对比维度 | Redis向量数据库 | 专业向量数据库(Milvus/Pinecone) |
|---|---|---|
| 部署成本 | 极低,基于现有Redis Stack即可部署,无额外组件 | 较高,需独立部署分布式集群,依赖对象存储、协调服务等组件 |
| 学习成本 | 极低,兼容Redis原生协议与Java客户端,复用现有技术栈 | 较高,需学习独立的API、索引配置与集群运维体系 |
| 数据规模 | 适配百万级到千万级向量数据 | 适配亿级以上超大规模向量数据,支持分布式分片与水平扩展 |
| 检索延迟 | 亚毫秒级,Redis原生内存级访问优势 | 毫秒级,分布式架构带来一定的网络开销 |
| 高级特性 | 满足基础向量检索与混合查询需求 | 支持向量压缩、多租户、动态schema、多模态向量、增量索引等高级特性 |
| 业务适配 | 中小规模数据量、快速迭代的业务,已有Redis技术栈的团队 | 超大规模数据、复杂检索场景、企业级多租户需求的业务 |
二、底层原理深度拆解
2.1 向量的本质与高维空间语义
向量的本质,是将非结构化数据转换为一组固定长度的浮点数数组,数组中的每一个数值,都对应着数据的一个底层特征。比如一段文本“Java并发编程原理”,通过Embedding模型转换为1024维的float数组,每一个数值都代表了文本的语义特征。
在高维空间中,语义、特征越相似的数据,对应的向量点之间的距离就越近。向量检索的核心逻辑,就是计算查询向量与库中所有向量的相似度,返回距离最近的TopK个结果,也就是常说的K近邻(KNN)检索。
2.2 Redis向量的存储结构
Redis向量支持两种存储载体,均能被RediSearch索引识别与检索:
- Hash结构:向量字段以BLOB二进制格式存储在Hash的field中,是性能最优、内存占用最低的存储方式。FLOAT32类型的向量,每个维度占用4个字节,1024维的向量仅占用4KB内存,存储效率极高。
- JSON结构:向量以浮点数组的形式存储在RedisJSON文档中,适合需要同时存储向量与复杂结构化数据的场景,内存占用略高于Hash结构。
无论哪种存储方式,向量数据都完全兼容Redis原生的持久化、主从同步、集群分片机制,无需额外的存储适配。
2.3 核心索引类型与底层实现
索引是向量检索性能的核心,Redis提供两种索引类型,分别适配不同的业务场景。
2.3.1 FLAT索引(精确KNN检索)
FLAT索引是暴力搜索索引,底层采用全量扫描的方式,计算查询向量与库中所有向量的相似度,最终返回距离最近的TopK结果。
- 核心优势:召回率100%,无任何精度损失,支持向量的实时增删改,数据更新后立即生效
- 核心劣势:数据量超过10万条后,检索延迟会显著上升,不适合大数据量场景
- 适用场景:万级以下小数据集、需要100%精确匹配的场景、数据更新频率极高的场景
2.3.2 HNSW索引(近似ANN检索)
HNSW全称Hierarchical Navigable Small World,即层次化导航小世界图,是目前工业界最主流的近似近邻检索算法。
- 底层原理:将高维向量构建成多层有向图结构,最底层包含所有向量节点,越往上的层级节点数量越少。检索时从最顶层的入口节点开始,逐层向下查找距离最近的节点,最终在最底层找到TopK结果,避免了全量扫描。
- 核心优势:检索延迟极低,百万级向量数据可实现亚毫秒级响应,适合大数据量高并发场景
- 核心劣势:有轻微的召回率损失,索引构建有一定的资源开销,数据实时更新性能弱于FLAT索引
- 适用场景:十万级以上大数据集、高并发检索场景、可接受轻微精度损失换取极致性能的业务
2.4 相似度度量算法全解析
Redis支持3种主流的相似度度量算法,分别适配不同的业务场景,算法的选择直接影响检索效果。
2.4.1 L2欧几里得距离
L2距离计算的是高维空间中两个向量点之间的直线距离,计算公式为:
- 距离取值范围:[0, +∞),值越小,向量相似度越高
- 适用场景:图像特征匹配、语音识别、用户行为特征匹配等对向量绝对值敏感的场景
2.4.2 内积(IP)
内积计算的是两个向量的点积,计算公式为:
- 取值范围:(-∞, +∞),值越大,向量相似度越高
- 核心特性:对向量的长度敏感,向量模长越大,内积值越高
- 适用场景:已做L2归一化的向量检索、推荐系统中的用户-物品匹配场景
2.4.3 余弦相似度
余弦相似度计算的是两个向量夹角的余弦值,衡量的是向量的方向相似度,与向量长度无关,计算公式为:
- 取值范围:[-1, 1],值越大,向量相似度越高,1代表完全相同
- 核心特性:不受向量模长影响,仅关注向量的方向,完美适配文本语义匹配
- 适用场景:文本语义检索、RAG场景、文档相似度匹配等NLP相关场景
❝
关键知识点:若向量已完成L2归一化(模长为1),则余弦相似度与内积的计算结果完全一致,可直接使用内积算法减少计算开销。
三、环境搭建与项目整合
3.1 环境快速搭建
Redis向量能力依赖RediSearch模块,推荐使用Redis Stack官方镜像一键部署,Docker部署命令如下:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:7.4.0-v0
- 6379端口:Redis原生服务端口
- 8001端口:Redis Insight可视化管理界面,可直接查看向量索引与数据
3.2 核心基础命令
先通过Redis CLI掌握核心命令,理解向量操作的核心逻辑:
- 创建FLAT向量索引
FT.CREATE idx_doc_flat ON HASH PREFIX 1 doc: SCHEMA content TEXT tags TAG vector VECTOR FLAT 6 TYPE FLOAT32 DIM 1024 DISTANCE_METRIC COSINE
2. 创建HNSW向量索引
FT.CREATE idx_doc_hnsw ON HASH PREFIX 1 doc: SCHEMA content TEXT tags TAG vector VECTOR HNSW 10 TYPE FLOAT32 DIM 1024 DISTANCE_METRIC COSINE M 16 EF_CONSTRUCTION 200 EF_RUNTIME 10
3. 写入向量数据
HSET doc:001 content "Java并发编程核心原理" tags "Java,后端,并发" vector "<向量二进制字节数组>"
4. KNN向量检索
FT.SEARCH idx_doc_hnsw "*=>[KNN 5 @vector $query_vec]" PARAMS 2 query_vec "<查询向量二进制字节数组>" SORTBY __vector_score LIMIT 0 5
3.3 Java项目环境整合
基于Spring Boot 3.2.x搭建项目,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.2.5</version>
<relativePath/>
</parent>
<groupId>com.jam.demo</groupId>
<artifactId>redis-vector-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>redis-vector-demo</name>
<properties>
<java.version>17</java.version>
<jedis.version>5.1.5</jedis.version>
<fastjson2.version>2.0.52</fastjson2.version>
<guava.version>33.1.0-jre</guava.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<springdoc.version>2.5.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<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>
application.yml配置文件:
spring:
application:
name: redis-vector-demo
data:
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 5000
jedis:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 3000
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/vector_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: root
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
swagger-ui:
path: /swagger-ui.html
enabled: true
api-docs:
enabled: true
path: /v3/api-docs
Redis配置类:
package com.jam.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* Redis配置类
* @author ken
* @since 2026-04-03
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
redisTemplate.setHashValueSerializer(stringRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
@Bean
public JedisPool jedisPool(JedisConnectionFactory jedisConnectionFactory) {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(10);
poolConfig.setMinIdle(5);
poolConfig.setMaxWaitMillis(3000);
return new JedisPool(poolConfig,
jedisConnectionFactory.getHostName(),
jedisConnectionFactory.getPort(),
jedisConnectionFactory.getTimeout(),
jedisConnectionFactory.getPassword(),
jedisConnectionFactory.getDatabase());
}
}
向量工具类,核心解决Java大端序与Redis要求的小端序转换问题:
package com.jam.demo.util;
import org.springframework.util.ObjectUtils;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* 向量处理工具类
* @author ken
* @since 2026-04-03
*/
public class VectorUtils {
private VectorUtils() {}
/**
* float向量数组转换为Redis要求的小端序字节数组
* @param vector 浮点向量数组
* @return 小端序字节数组
*/
public static byte[] floatVectorToLittleEndianBytes(float[] vector) {
if (ObjectUtils.isEmpty(vector)) {
return new byte[0];
}
ByteBuffer buffer = ByteBuffer.allocate(Float.BYTES * vector.length);
buffer.order(ByteOrder.LITTLE_ENDIAN);
for (float value : vector) {
buffer.putFloat(value);
}
return buffer.array();
}
/**
* Redis小端序字节数组转换为float向量数组
* @param bytes 小端序字节数组
* @return 浮点向量数组
*/
public static float[] littleEndianBytesToFloatVector(byte[] bytes) {
if (ObjectUtils.isEmpty(bytes)) {
return new float[0];
}
int floatCount = bytes.length / Float.BYTES;
float[] vector = new float[floatCount];
ByteBuffer buffer = ByteBuffer.wrap(bytes);
buffer.order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < floatCount; i++) {
vector[i] = buffer.getFloat();
}
return vector;
}
/**
* 向量L2归一化处理
* @param vector 原始向量
* @return 归一化后的向量
*/
public static float[] normalizeVector(float[] vector) {
if (ObjectUtils.isEmpty(vector)) {
return new float[0];
}
double sum = 0.0;
for (float value : vector) {
sum += Math.pow(value, 2);
}
float norm = (float) Math.sqrt(sum);
if (norm == 0) {
return vector;
}
float[] normalizedVector = new float[vector.length];
for (int i = 0; i < vector.length; i++) {
normalizedVector[i] = vector[i] / norm;
}
return normalizedVector;
}
}
四、核心功能全实战
4.1 向量索引的创建与管理
实体类定义:
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 向量文档实体
* @author ken
* @since 2026-04-03
*/
@Data
@TableName("t_vector_document")
@Schema(description = "向量文档实体")
public class VectorDocument {
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID", example = "1")
private Long id;
@Schema(description = "文档唯一ID", example = "doc:001")
private String docId;
@Schema(description = "文档内容", example = "Java并发编程核心原理")
private String content;
@Schema(description = "标签,逗号分隔", example = "Java,后端,并发")
private String tags;
@Schema(description = "向量维度", example = "1024")
private Integer vectorDimension;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}
Mapper接口:
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.VectorDocument;
import org.apache.ibatis.annotations.Mapper;
/**
* 向量文档Mapper
* @author ken
* @since 2026-04-03
*/
@Mapper
public interface VectorDocumentMapper extends BaseMapper<VectorDocument> {
}
索引管理Service接口:
package com.jam.demo.service;
import com.jam.demo.entity.VectorDocument;
import com.jam.demo.vo.VectorSearchResult;
import java.util.List;
/**
* Redis向量服务接口
* @author ken
* @since 2026-04-03
*/
public interface RedisVectorService {
boolean createFlatIndex(String indexName, int dimension, String distanceMetric);
boolean createHnswIndex(String indexName, int dimension, String distanceMetric, int m, int efConstruction, int efRuntime);
boolean deleteIndex(String indexName);
boolean existIndex(String indexName);
boolean saveVectorDocument(VectorDocument document, float[] vector);
boolean deleteVectorDocument(String docId);
List<VectorSearchResult> knnSearch(String indexName, float[] queryVector, int topK);
List<VectorSearchResult> hybridSearch(String indexName, float[] queryVector, int topK, String tagFilter);
}
索引管理Service实现类核心方法:
package com.jam.demo.service.impl;
import com.jam.demo.entity.VectorDocument;
import com.jam.demo.mapper.VectorDocumentMapper;
import com.jam.demo.service.RedisVectorService;
import com.jam.demo.util.VectorUtils;
import com.jam.demo.vo.VectorSearchResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.search.Document;
import redis.clients.jedis.search.Query;
import redis.clients.jedis.search.SearchResult;
import jakarta.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Redis向量服务实现类
* @author ken
* @since 2026-04-03
*/
@Slf4j
@Service
public class RedisVectorServiceImpl implements RedisVectorService {
private static final String DOC_KEY_PREFIX = "doc:";
private static final String VECTOR_FIELD_NAME = "vector";
private static final String CONTENT_FIELD_NAME = "content";
private static final String TAGS_FIELD_NAME = "tags";
private static final String SCORE_FIELD_NAME = "__vector_score";
@Resource
private JedisPool jedisPool;
@Resource
private VectorDocumentMapper vectorDocumentMapper;
@Resource
private TransactionTemplate transactionTemplate;
@Override
public boolean createFlatIndex(String indexName, int dimension, String distanceMetric) {
if (!StringUtils.hasText(indexName) || dimension <= 0 || !StringUtils.hasText(distanceMetric)) {
log.error("创建FLAT索引参数异常");
return false;
}
try (Jedis jedis = jedisPool.getResource()) {
String schema = String.format(
"ON HASH PREFIX 1 %s SCHEMA %s TEXT %s TAG %s VECTOR FLAT 6 TYPE FLOAT32 DIM %d DISTANCE_METRIC %s",
DOC_KEY_PREFIX, CONTENT_FIELD_NAME, TAGS_FIELD_NAME, VECTOR_FIELD_NAME, dimension, distanceMetric
);
jedis.ftCreate(indexName, schema.split(" "));
log.info("FLAT索引创建成功,索引名:{}", indexName);
return true;
} catch (Exception e) {
log.error("FLAT索引创建失败,索引名:{}", indexName, e);
return false;
}
}
@Override
public boolean createHnswIndex(String indexName, int dimension, String distanceMetric, int m, int efConstruction, int efRuntime) {
if (!StringUtils.hasText(indexName) || dimension <= 0 || !StringUtils.hasText(distanceMetric)) {
log.error("创建HNSW索引参数异常");
return false;
}
try (Jedis jedis = jedisPool.getResource()) {
String schema = String.format(
"ON HASH PREFIX 1 %s SCHEMA %s TEXT %s TAG %s VECTOR HNSW 10 TYPE FLOAT32 DIM %d DISTANCE_METRIC %s M %d EF_CONSTRUCTION %d EF_RUNTIME %d",
DOC_KEY_PREFIX, CONTENT_FIELD_NAME, TAGS_FIELD_NAME, VECTOR_FIELD_NAME, dimension, distanceMetric, m, efConstruction, efRuntime
);
jedis.ftCreate(indexName, schema.split(" "));
log.info("HNSW索引创建成功,索引名:{}", indexName);
return true;
} catch (Exception e) {
log.error("HNSW索引创建失败,索引名:{}", indexName, e);
return false;
}
}
@Override
public boolean deleteIndex(String indexName) {
if (!StringUtils.hasText(indexName)) {
return false;
}
try (Jedis jedis = jedisPool.getResource()) {
jedis.ftDropIndex(indexName);
log.info("索引删除成功,索引名:{}", indexName);
return true;
} catch (Exception e) {
log.error("索引删除失败,索引名:{}", indexName, e);
return false;
}
}
@Override
public boolean existIndex(String indexName) {
if (!StringUtils.hasText(indexName)) {
return false;
}
try (Jedis jedis = jedisPool.getResource()) {
jedis.ftInfo(indexName);
return true;
} catch (Exception e) {
return false;
}
}
}
4.2 向量数据的增删改查
Service实现类新增增删改查核心方法:
@Override
public boolean saveVectorDocument(VectorDocument document, float[] vector) {
if (ObjectUtils.isEmpty(document) || !StringUtils.hasText(document.getDocId()) || ObjectUtils.isEmpty(vector)) {
log.error("保存向量文档参数异常");
return false;
}
return transactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
int dbResult;
VectorDocument existDoc = vectorDocumentMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VectorDocument>()
.eq(VectorDocument::getDocId, document.getDocId())
);
if (ObjectUtils.isEmpty(existDoc)) {
dbResult = vectorDocumentMapper.insert(document);
} else {
document.setId(existDoc.getId());
dbResult = vectorDocumentMapper.updateById(document);
}
if (dbResult <= 0) {
status.setRollbackOnly();
log.error("向量文档数据库保存失败,docId:{}", document.getDocId());
return false;
}
try (Jedis jedis = jedisPool.getResource()) {
byte[] vectorBytes = VectorUtils.floatVectorToLittleEndianBytes(vector);
Map<String, String> hashData = Map.of(
CONTENT_FIELD_NAME, document.getContent(),
TAGS_FIELD_NAME, ObjectUtils.isEmpty(document.getTags()) ? "" : document.getTags()
);
jedis.hset(DOC_KEY_PREFIX + document.getDocId(), hashData);
jedis.hset(DOC_KEY_PREFIX + document.getDocId(), VECTOR_FIELD_NAME, vectorBytes);
}
log.info("向量文档保存成功,docId:{}", document.getDocId());
return true;
} catch (Exception e) {
status.setRollbackOnly();
log.error("向量文档保存失败,docId:{}", document.getDocId(), e);
return false;
}
}
});
}
@Override
public boolean deleteVectorDocument(String docId) {
if (!StringUtils.hasText(docId)) {
return false;
}
return transactionTemplate.execute(new TransactionCallback<Boolean>() {
@Override
public Boolean doInTransaction(TransactionStatus status) {
try {
vectorDocumentMapper.delete(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VectorDocument>()
.eq(VectorDocument::getDocId, docId)
);
try (Jedis jedis = jedisPool.getResource()) {
jedis.del(DOC_KEY_PREFIX + docId);
}
log.info("向量文档删除成功,docId:{}", docId);
return true;
} catch (Exception e) {
status.setRollbackOnly();
log.error("向量文档删除失败,docId:{}", docId, e);
return false;
}
}
});
}
检索结果VO定义:
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 向量检索结果VO
* @author ken
* @since 2026-04-03
*/
@Data
@Schema(description = "向量检索结果VO")
public class VectorSearchResult {
@Schema(description = "文档ID", example = "doc:001")
private String docId;
@Schema(description = "文档内容", example = "Java并发编程核心原理")
private String content;
@Schema(description = "文档标签", example = "Java,后端,并发")
private String tags;
@Schema(description = "相似度得分", example = "0.92")
private Double score;
}
4.3 精准KNN与近似ANN检索
Service实现类新增检索核心方法:
@Override
public List<VectorSearchResult> knnSearch(String indexName, float[] queryVector, int topK) {
if (!StringUtils.hasText(indexName) || ObjectUtils.isEmpty(queryVector) || topK <= 0) {
log.error("KNN检索参数异常");
return new ArrayList<>();
}
try (Jedis jedis = jedisPool.getResource()) {
byte[] queryVectorBytes = VectorUtils.floatVectorToLittleEndianBytes(queryVector);
String queryString = String.format("*=>[KNN %d @%s $query_vec]", topK, VECTOR_FIELD_NAME);
Query query = new Query(queryString)
.addParam("query_vec", queryVectorBytes)
.setSortBy(SCORE_FIELD_NAME, true)
.limit(0, topK)
.returnFields(CONTENT_FIELD_NAME, TAGS_FIELD_NAME, SCORE_FIELD_NAME);
SearchResult searchResult = jedis.ftSearch(indexName, query);
List<Document> documents = searchResult.getDocuments();
if (CollectionUtils.isEmpty(documents)) {
return new ArrayList<>();
}
List<VectorSearchResult> resultList = new ArrayList<>(documents.size());
for (Document doc : documents) {
VectorSearchResult result = new VectorSearchResult();
result.setDocId(doc.getId().replace(DOC_KEY_PREFIX, ""));
result.setContent(doc.getString(CONTENT_FIELD_NAME));
result.setTags(doc.getString(TAGS_FIELD_NAME));
result.setScore(1 - Double.parseDouble(doc.getString(SCORE_FIELD_NAME)));
resultList.add(result);
}
return resultList;
} catch (Exception e) {
log.error("KNN检索失败,索引名:{}", indexName, e);
return new ArrayList<>();
}
}
@Override
public List<VectorSearchResult> hybridSearch(String indexName, float[] queryVector, int topK, String tagFilter) {
if (!StringUtils.hasText(indexName) || ObjectUtils.isEmpty(queryVector) || topK <= 0 || !StringUtils.hasText(tagFilter)) {
log.error("混合检索参数异常");
return new ArrayList<>();
}
try (Jedis jedis = jedisPool.getResource()) {
byte[] queryVectorBytes = VectorUtils.floatVectorToLittleEndianBytes(queryVector);
String queryString = String.format("@%s:{%s} =>[KNN %d @%s $query_vec]", TAGS_FIELD_NAME, tagFilter, topK, VECTOR_FIELD_NAME);
Query query = new Query(queryString)
.addParam("query_vec", queryVectorBytes)
.setSortBy(SCORE_FIELD_NAME, true)
.limit(0, topK)
.returnFields(CONTENT_FIELD_NAME, TAGS_FIELD_NAME, SCORE_FIELD_NAME);
SearchResult searchResult = jedis.ftSearch(indexName, query);
List<Document> documents = searchResult.getDocuments();
if (CollectionUtils.isEmpty(documents)) {
return new ArrayList<>();
}
List<VectorSearchResult> resultList = new ArrayList<>(documents.size());
for (Document doc : documents) {
VectorSearchResult result = new VectorSearchResult();
result.setDocId(doc.getId().replace(DOC_KEY_PREFIX, ""));
result.setContent(doc.getString(CONTENT_FIELD_NAME));
result.setTags(doc.getString(TAGS_FIELD_NAME));
result.setScore(1 - Double.parseDouble(doc.getString(SCORE_FIELD_NAME)));
resultList.add(result);
}
return resultList;
} catch (Exception e) {
log.error("混合检索失败,索引名:{}", indexName, e);
return new ArrayList<>();
}
}
Controller层接口定义:
package com.jam.demo.controller;
import com.jam.demo.entity.VectorDocument;
import com.jam.demo.service.RedisVectorService;
import com.jam.demo.util.VectorUtils;
import com.jam.demo.vo.VectorSearchResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Random;
/**
* Redis向量数据库接口
* @author ken
* @since 2026-04-03
*/
@Slf4j
@RestController
@RequestMapping("/api/redis/vector")
@Tag(name = "Redis向量数据库接口", description = "向量索引管理、数据操作与检索接口")
public class RedisVectorController {
@Resource
private RedisVectorService redisVectorService;
private static final int VECTOR_DIMENSION = 1024;
private final Random random = new Random();
@PostMapping("/index/flat")
@Operation(summary = "创建FLAT精确索引", description = "创建FLAT类型的向量索引,支持100%召回率的精确检索")
public ResponseEntity<Boolean> createFlatIndex(
@Parameter(description = "索引名称", required = true, example = "idx_doc_flat") @RequestParam String indexName,
@Parameter(description = "距离度量算法", required = true, example = "COSINE") @RequestParam(defaultValue = "COSINE") String distanceMetric) {
boolean result = redisVectorService.createFlatIndex(indexName, VECTOR_DIMENSION, distanceMetric);
return ResponseEntity.ok(result);
}
@PostMapping("/index/hnsw")
@Operation(summary = "创建HNSW近似索引", description = "创建HNSW类型的向量索引,支持大数据量低延迟的近似检索")
public ResponseEntity<Boolean> createHnswIndex(
@Parameter(description = "索引名称", required = true, example = "idx_doc_hnsw") @RequestParam String indexName,
@Parameter(description = "距离度量算法", required = true, example = "COSINE") @RequestParam(defaultValue = "COSINE") String distanceMetric,
@Parameter(description = "每层节点邻居数", example = "16") @RequestParam(defaultValue = "16") int m,
@Parameter(description = "索引构建探索深度", example = "200") @RequestParam(defaultValue = "200") int efConstruction,
@Parameter(description = "检索探索深度", example = "10") @RequestParam(defaultValue = "10") int efRuntime) {
boolean result = redisVectorService.createHnswIndex(indexName, VECTOR_DIMENSION, distanceMetric, m, efConstruction, efRuntime);
return ResponseEntity.ok(result);
}
@DeleteMapping("/index")
@Operation(summary = "删除向量索引", description = "删除指定名称的向量索引")
public ResponseEntity<Boolean> deleteIndex(
@Parameter(description = "索引名称", required = true, example = "idx_doc_flat") @RequestParam String indexName) {
boolean result = redisVectorService.deleteIndex(indexName);
return ResponseEntity.ok(result);
}
@GetMapping("/index/exist")
@Operation(summary = "判断索引是否存在", description = "检查指定名称的向量索引是否存在")
public ResponseEntity<Boolean> existIndex(
@Parameter(description = "索引名称", required = true, example = "idx_doc_flat") @RequestParam String indexName) {
boolean result = redisVectorService.existIndex(indexName);
return ResponseEntity.ok(result);
}
@PostMapping("/document")
@Operation(summary = "保存向量文档", description = "保存向量文档与对应的向量数据,同时写入MySQL与Redis")
public ResponseEntity<Boolean> saveDocument(@RequestBody VectorDocument document) {
float[] vector = new float[VECTOR_DIMENSION];
for (int i = 0; i < VECTOR_DIMENSION; i++) {
vector[i] = random.nextFloat() * 2 - 1;
}
vector = VectorUtils.normalizeVector(vector);
boolean result = redisVectorService.saveVectorDocument(document, vector);
return ResponseEntity.ok(result);
}
@DeleteMapping("/document")
@Operation(summary = "删除向量文档", description = "删除指定docId的向量文档,同时删除MySQL与Redis中的数据")
public ResponseEntity<Boolean> deleteDocument(
@Parameter(description = "文档ID", required = true, example = "001") @RequestParam String docId) {
boolean result = redisVectorService.deleteVectorDocument(docId);
return ResponseEntity.ok(result);
}
@PostMapping("/search/knn")
@Operation(summary = "KNN向量检索", description = "执行K近邻向量检索,返回TopK个相似结果")
public ResponseEntity<List<VectorSearchResult>> knnSearch(
@Parameter(description = "索引名称", required = true, example = "idx_doc_hnsw") @RequestParam String indexName,
@Parameter(description = "返回结果数量", required = true, example = "5") @RequestParam(defaultValue = "5") int topK) {
float[] queryVector = new float[VECTOR_DIMENSION];
for (int i = 0; i < VECTOR_DIMENSION; i++) {
queryVector[i] = random.nextFloat() * 2 - 1;
}
queryVector = VectorUtils.normalizeVector(queryVector);
List<VectorSearchResult> resultList = redisVectorService.knnSearch(indexName, queryVector, topK);
return ResponseEntity.ok(resultList);
}
@PostMapping("/search/hybrid")
@Operation(summary = "混合检索", description = "执行标签过滤+向量检索的混合查询,先过滤再检索,保证性能")
public ResponseEntity<List<VectorSearchResult>> hybridSearch(
@Parameter(description = "索引名称", required = true, example = "idx_doc_hnsw") @RequestParam String indexName,
@Parameter(description = "返回结果数量", required = true, example = "5") @RequestParam(defaultValue = "5") int topK,
@Parameter(description = "标签过滤条件", required = true, example = "Java") @RequestParam String tagFilter) {
float[] queryVector = new float[VECTOR_DIMENSION];
for (int i = 0; i < VECTOR_DIMENSION; i++) {
queryVector[i] = random.nextFloat() * 2 - 1;
}
queryVector = VectorUtils.normalizeVector(queryVector);
List<VectorSearchResult> resultList = redisVectorService.hybridSearch(indexName, queryVector, topK, tagFilter);
return ResponseEntity.ok(resultList);
}
}
五、落地实战
5.1 RAG检索增强生成全流程实现
RAG是目前大模型落地最核心的场景,Redis向量数据库可作为RAG的底层检索引擎,完整流程如下:
- 文档加载与分块:将PDF、Word、TXT等文档拆分为固定长度的文本块
- 文本向量化:通过Embedding模型将文本块转换为固定维度的向量
- 向量存储:将文本块与向量写入Redis,构建向量索引
- 查询向量化:将用户的提问转换为向量
- 相似检索:从Redis中检索与提问向量最相似的TopK个文本块
- Prompt构建:将检索到的文本块与用户提问拼接为Prompt,传入大模型
- 回答生成:大模型基于检索到的上下文,生成精准的回答
核心RAG服务实现:
package com.jam.demo.service;
import com.jam.demo.vo.VectorSearchResult;
import java.util.List;
/**
* RAG服务接口
* @author ken
* @since 2026-04-03
*/
public interface RagService {
void loadDocumentToVectorDb(String filePath);
String generateAnswer(String userQuestion, int topK);
}
package com.jam.demo.service.impl;
import com.alibaba.fastjson2.JSON;
import com.jam.demo.entity.VectorDocument;
import com.jam.demo.service.RagService;
import com.jam.demo.service.RedisVectorService;
import com.jam.demo.util.VectorUtils;
import com.jam.demo.vo.VectorSearchResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* RAG服务实现类
* @author ken
* @since 2026-04-03
*/
@Slf4j
@Service
public class RagServiceImpl implements RagService {
private static final String INDEX_NAME = "idx_rag_doc";
private static final int VECTOR_DIMENSION = 1024;
private static final int CHUNK_SIZE = 500;
private static final int CHUNK_OVERLAP = 50;
@Value("${embedding.api.url}")
private String embeddingApiUrl;
@Value("${embedding.api.key}")
private String embeddingApiKey;
@Value("${llm.api.url}")
private String llmApiUrl;
@Value("${llm.api.key}")
private String llmApiKey;
@Resource
private RedisVectorService redisVectorService;
@Resource
private RestTemplate restTemplate;
@Override
public void loadDocumentToVectorDb(String filePath) {
if (!StringUtils.hasText(filePath)) {
log.error("文档路径为空");
return;
}
try {
if (!redisVectorService.existIndex(INDEX_NAME)) {
redisVectorService.createHnswIndex(INDEX_NAME, VECTOR_DIMENSION, "COSINE", 16, 200, 10);
}
String content = Files.readString(Paths.get(filePath));
List<String> chunks = splitTextToChunks(content);
for (String chunk : chunks) {
float[] vector = getTextEmbedding(chunk);
if (vector.length != VECTOR_DIMENSION) {
log.error("向量维度不匹配,跳过该文本块");
continue;
}
VectorDocument document = new VectorDocument();
document.setDocId(UUID.randomUUID().toString().replace("-", ""));
document.setContent(chunk);
document.setTags("RAG");
document.setVectorDimension(VECTOR_DIMENSION);
document.setCreateTime(LocalDateTime.now());
document.setUpdateTime(LocalDateTime.now());
redisVectorService.saveVectorDocument(document, vector);
}
log.info("文档加载完成,共拆分{}个文本块", chunks.size());
} catch (IOException e) {
log.error("文档读取失败", e);
}
}
@Override
public String generateAnswer(String userQuestion, int topK) {
if (!StringUtils.hasText(userQuestion)) {
return "请输入有效的问题";
}
float[] queryVector = getTextEmbedding(userQuestion);
List<VectorSearchResult> searchResults = redisVectorService.knnSearch(INDEX_NAME, queryVector, topK);
if (CollectionUtils.isEmpty(searchResults)) {
return "未检索到相关的上下文信息";
}
StringBuilder contextBuilder = new StringBuilder();
for (VectorSearchResult result : searchResults) {
contextBuilder.append(result.getContent()).append("\n\n");
}
String prompt = String.format(
"请基于以下上下文信息回答用户的问题,若上下文中没有相关信息,请直接回答“未找到相关信息”,禁止编造内容。\n上下文信息:\n%s\n用户问题:%s",
contextBuilder, userQuestion
);
return callLlm(prompt);
}
private List<String> splitTextToChunks(String text) {
List<String> chunks = new ArrayList<>();
if (!StringUtils.hasText(text)) {
return chunks;
}
int length = text.length();
int start = 0;
while (start < length) {
int end = Math.min(start + CHUNK_SIZE, length);
if (end < length) {
int lastPunctuation = Math.max(
text.lastIndexOf("。", end),
Math.max(text.lastIndexOf("!", end), text.lastIndexOf("?", end))
);
if (lastPunctuation > start) {
end = lastPunctuation + 1;
}
}
chunks.add(text.substring(start, end).trim());
start = end - CHUNK_OVERLAP;
if (start >= length) {
break;
}
}
return chunks;
}
private float[] getTextEmbedding(String text) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + embeddingApiKey);
Map<String, Object> requestBody = Map.of("input", text, "model", "text-embedding-v1");
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
String response = restTemplate.postForObject(embeddingApiUrl, request, String.class);
Map<String, Object> responseMap = JSON.parseObject(response);
List<Float> embeddingList = (List<Float>) ((Map<String, Object>) ((List<Object>) responseMap.get("data")).get(0)).get("embedding");
float[] embedding = new float[embeddingList.size()];
for (int i = 0; i < embeddingList.size(); i++) {
embedding[i] = embeddingList.get(i);
}
return VectorUtils.normalizeVector(embedding);
} catch (Exception e) {
log.error("文本向量化失败", e);
return new float[VECTOR_DIMENSION];
}
}
private String callLlm(String prompt) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + llmApiKey);
Map<String, Object> requestBody = Map.of(
"model", "qwen-max",
"messages", List.of(Map.of("role", "user", "content", prompt)),
"temperature", 0.1
);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
String response = restTemplate.postForObject(llmApiUrl, request, String.class);
Map<String, Object> responseMap = JSON.parseObject(response);
return (String) ((Map<String, Object>) ((List<Object>) responseMap.get("choices")).get(0)).get("content");
} catch (Exception e) {
log.error("大模型调用失败", e);
return "大模型调用异常,请稍后重试";
}
}
}
5.2 商品相似度推荐系统
基于商品的标题、描述、属性等信息生成商品特征向量,通过Redis向量检索实现相似商品推荐,同时支持品类、价格区间的过滤,实现精准的个性化推荐。核心逻辑与RAG场景一致,只需将商品信息作为文本块生成向量,检索时增加品类、价格等标签过滤条件,即可实现相似商品推荐。
5.3 用户画像相似度匹配
将用户的行为数据、偏好标签、消费特征等信息转换为用户画像向量,通过Redis向量检索实现相似用户匹配,可用于好友推荐、用户分群、精准营销等场景。针对百万级用户数据,HNSW索引可实现毫秒级的相似用户检索,完全满足高并发的业务需求。
六、性能调优与最佳实践
6.1 索引选型与参数调优
-
索引选型规则
- 数据量<10万条,或需要100%召回率:选择FLAT索引
- 数据量>10万条,或高并发检索场景:选择HNSW索引
- 数据更新频率极高:优先选择FLAT索引,HNSW索引频繁更新会降低索引质量
-
HNSW核心参数调优
- M值:每层每个节点的邻居数量,推荐取值16-64。M值越大,索引占用内存越高,构建速度越慢,但召回率与检索速度越高。文本语义场景推荐16-32,图像特征场景推荐32-64。
- EF_CONSTRUCTION:索引构建时的探索深度,推荐取值100-500。值越大,索引构建速度越慢,索引质量越高,召回率越高。生产环境推荐200-300。
- EF_RUNTIME:检索时的探索深度,推荐取值10-100。值越大,检索速度越慢,召回率越高。可根据业务的延迟与召回率要求动态调整,生产环境默认10-50。
6.2 内存优化策略
- 优先使用Hash结构+BLOB格式存储向量,相比JSON数组可节省30%以上的内存
- 向量维度选择够用即可,文本语义场景1024维已能满足绝大多数需求,无需盲目使用4096等高维向量
- 对向量进行L2归一化,可统一向量取值范围,减少计算开销,同时兼容内积与余弦相似度算法
- 开启Redis的内存压缩功能,针对非向量字段进行压缩,进一步降低内存占用
- 生产环境建议开启RDB+AOF混合持久化,平衡数据安全性与性能
6.3 高可用部署方案
Redis向量数据库完全兼容Redis原生的高可用架构,生产环境推荐两种部署方案:
- 哨兵模式:1主2从+3哨兵,适配中小规模数据量,保证故障自动切换,数据零丢失
- 集群模式:3主3从,适配百万级以上大数据量,通过分片水平扩展,支持更大的数据集与更高的并发
❝
注意:向量索引会随Redis数据同步到从节点,检索请求可路由到从节点执行,实现读写分离,进一步提升集群的并发能力。
6.4 生产级最佳实践
- 必须先创建索引,再写入向量数据,先写入数据再创建索引,不会对已有的数据构建索引
- 混合检索场景,必须将过滤条件写在
=>符号之前,实现先过滤后检索,避免全量检索后过滤导致的性能灾难 - 向量写入前必须校验维度,确保与索引定义的维度一致,维度不一致的向量无法被索引识别
- 批量写入向量数据时,使用Redis Pipeline批量执行命令,可提升5倍以上的写入性能
- 生产环境禁止使用
KEYS *等全量扫描命令,避免阻塞Redis主线程 - 定期通过
FT.INFO命令查看索引状态,监控索引构建进度、内存占用、检索延迟等指标
七、高频坑点与避坑指南
-
字节序错误导致检索完全失效
- 问题:Java默认使用大端序存储浮点数,而Redis向量要求使用小端序的字节数组,字节序错误会导致向量完全无法匹配
- 解决方案:必须通过ByteBuffer设置小端序进行向量与字节数组的转换,本文提供的VectorUtils工具类已解决该问题
-
向量未归一化导致余弦相似度检索结果异常
- 问题:使用余弦相似度算法时,向量模长不一致会导致相似度计算结果不符合预期
- 解决方案:向量写入与查询前,统一进行L2归一化处理,保证所有向量的模长为1
-
混合检索过滤条件位置错误导致性能极差
- 问题:将过滤条件写在
=>符号之后,会先执行全量向量检索,再对结果进行过滤,数据量大时延迟极高 - 解决方案:必须将过滤条件写在
=>符号之前,实现先过滤缩小数据集,再执行向量检索
- 问题:将过滤条件写在
-
索引创建顺序错误导致数据无法被检索
- 问题:先写入向量数据,再创建索引,已写入的数据不会被索引构建,导致检索不到数据
- 解决方案:严格遵循先创建索引,再写入数据的顺序;若已先写入数据,可删除索引后重新创建,重建索引会扫描全量数据
-
HNSW索引频繁更新导致召回率下降
- 问题:HNSW索引针对高频更新的场景,会出现图结构质量下降,导致召回率持续降低
- 解决方案:高频更新场景优先使用FLAT索引;若必须使用HNSW索引,建议在低峰期定期重建索引,保证索引质量
-
向量维度不匹配导致写入失败
- 问题:写入的向量维度与索引定义的维度不一致,导致向量无法被索引识别,检索不到该数据
- 解决方案:写入前严格校验向量维度,确保与索引定义的维度完全一致
-
Redis内存淘汰导致向量数据丢失
- 问题:开启了allkeys-lru等内存淘汰策略,当内存满时,向量数据会被淘汰,导致索引失效
- 解决方案:向量数据所在的Redis实例,必须将maxmemory-policy设置为noeviction,禁止内存淘汰;若需缓存与向量混用,建议分开部署实例
总结
Redis向量数据库,为Java开发者提供了一个低成本、高可用、易落地的向量检索方案。它无需额外部署新组件,完全复用现有Redis技术栈,即可快速实现企业级的向量检索能力,完美适配RAG、智能推荐、用户画像匹配等大模型时代的核心业务场景。
对于中小规模数据量、快速迭代的业务,Redis向量数据库是最优选择;对于超大规模数据、复杂检索场景,也可作为轻量级的验证方案,快速完成业务POC验证。