Redis 8 深度实战:把向量数据库塞进缓存——Vector Set + Query Engine 手撸生产级语义缓存,One Redis 时代的内存架构革命
关键词:Redis 8、One Redis、Vector Set、Redis Query Engine、RedisJSON、语义缓存、RAG、HNSW、向量数据库
一、背景介绍:我们为什么需要"会向量检索的缓存"
如果你在 2024 年做一个 RAG(检索增强生成)系统,技术选型通常是这样的:
- 用 Redis 做缓存(命中就直接返回,省一次 LLM 调用);
- 用 Milvus / Pinecone / pgvector 做向量检索(语义相似度召回);
- 用 PostgreSQL / MongoDB 做业务存储;
- 再用一层 RedisJSON 或外围对象存储兜住文档本体。
四套系统、四种客户端、四种运维心智模型。更痛苦的是:缓存层和向量层是两张皮。用户问"A 和 B 有什么区别",你先用向量库召回了答案,转头又去 Redis 里查"这个问题我是不是回答过"——两次网络往返、两套索引、两份内存账单。
Redis 8 想干掉的就是这种割裂。2025 年 Redis Labs 把原先分散在 RediSearch、RedisJSON、RedisBloom、RedisTimeSeries 等模块里的能力一次性折叠进核心,对外喊出了 "One Redis" 的口号。再加上 Salvatore Sanfilippo(antirez,Redis 之父)回归后亲手设计的原生 Vector Set(向量集合) 数据类型,Redis 第一次在"核心数据结构"层面拥有了向量检索能力——不是外挂模块,不是插件,是和 String、Hash、ZSet 平起平坐的一等公民。
这篇文章不聊 PR 稿里的那些百分比,而是带你从架构到代码把"缓存 + 向量库 + 文档库"三合一真正跑起来:用 Vector Set 搭一个生产级的语义缓存层,用 Query Engine 做带元数据过滤的混合检索,再用 io-threads 和内存量化把性能榨干。读完你应该能回答三个问题:Redis 8 的向量能力和 Milvus 到底差在哪、什么时候该用、代码怎么写才不踩坑。
先说清楚一个事实,免得被营销话术带偏:Redis 8 的官方基准里确实有"命令延迟降低 87%""吞吐量翻倍""查询吞吐提升 16 倍"这类数字,但它们是在特定硬件、特定命令、特定并发下测出来的,不是你随便装一个就能白捡的性能。本文的代码和调优建议是实打实可落地的,但性能数字请你在自己的机器上用 redis-benchmark 和真实数据重新验证——这一点对任何"官方基准"都成立。
二、核心概念:One Redis 到底把什么塞进了内核
2.1 模块折叠:从"装一堆插件"到"开箱即用"
在 Redis 7 及以前,如果你想同时用向量检索、JSON、概率结构,流程是这样的:
# 老世界:每个能力都是独立模块,版本要对齐,升级要操心兼容性
redis-server --loadmodule ./redisearch.so --loadmodule ./rejson.so --loadmodule ./redisbloom.so
Redis 8 把这些全部内置:
| 能力 | 老世界 | Redis 8 |
|---|---|---|
| 向量检索 | RediSearch 模块 | 原生 Vector Set + Query Engine |
| JSON 文档 | RedisJSON 模块 | JSON.* 内置命令 |
| 概率结构 | RedisBloom 模块 | BF.* / CF.* / CMS.* / TOPK.* / TDIGEST.* 内置 |
| 时间序列 | RedisTimeSeries 模块 | TS.* 内置 |
| 全文/混合检索 | RediSearch 模块 | Query Engine(FT.* 升级版) |
对工程团队来说,最大的收益不是"少装几个 .so",而是版本对齐焦虑消失:以前升级 RedisJSON 要担心和 RediSearch 的 ABI 冲突,现在官方一个 GA 包把兼容性一次性锁死。
顺带一个选型提醒:2024 年 Redis 把许可证从 BSD 改成了 RSALv3/SSPLv3,引发社区分裂,Linux 基金会随之孵化了 Valkey 这个 fork。2025 年 Redis 8 又重新采用了 AGPLv3。如果你公司的合规红线对某些许可证敏感,Valkey 和 Redis 8 都要纳入评估——本文只谈技术,许可证问题请法务拍板。
2.2 Vector Set:antirez 回归后的第一个大招
Vector Set 是 Redis 8 最值得聊的原生类型。它基于 HNSW(Hierarchical Navigable Small World,分层可导航小世界图)实现,专门为高维向量的近似最近邻(ANN)搜索而生。核心命令只有几个,但组合起来很能打:
# 1) 往向量集合里塞一个元素,附带它的向量(逗号分隔的浮点串)
# 语法:VADD <key> [REDUCE <dim>] [CAS|NOCAS] [NOINDEX] [SETATTR <json>] <score> <element> <vec>
VADD docs 1.0 "q:what_is_redis" "-0.12,0.34,0.08,-0.91,0.45,..."
# 2) 相似度查询:按 KNN 取 Top5,并带上相似度分数
# 语法:VSIM <key> [KNN <n> | RANGE <r>] [EF <n>] [FILTER <f>] [WITHSCORES] <element|vec>
VSIM docs KNN 5 WITHSCORES "q:what_is_redis"
# 返回示例:["q:what_is_redis", "1", "q:redis_vs_memcached", "0.93", ...]
# 3) 也可以直接拿一个原始向量去查,不要求是已存在的元素
VSIM docs KNN 3 WITHSCORES "-0.10,0.30,0.05,-0.88,0.40,..."
# 4) 给元素挂元数据(JSON),用于后续混合过滤
VSETATTR docs "q:what_is_redis" '{"tenant":"acme","lang":"zh"}'
# 5) 查看集合状态
VINFO docs # 全局信息:元素数、维度、HNSW 层数等
VCARD docs # 元素总数
VDIM docs # 向量维度
VREM docs "q:stale" # 删除元素
几个容易踩的点,我提前点出来:
- 分数(score)和相似度是两回事。VADD 里的
1.0是元素自己的标量分(可用于按分排序/范围剪枝),而 VSIM 返回的WITHSCORES是相似度(余弦场景下越接近 1 越相似)。别把 VADD 的分数当成相似度阈值去比对。 REDUCE <dim>是降维,不是压缩。它会在写入时把高维向量线性投影到低维空间(dim 必须小于原始维度),换查询速度但牺牲精度。适合"亿级向量但只求个大概"的场景。EF控制搜索质量:ef 越大召回越准但越慢,类似 HNSW 里的 efSearch。默认够用,追求极致召回时再调。CAS/NOCAS:并发写入时是否做"版本校验",避免后写覆盖先写。生产环境多写竞争强烈建议带CAS。
2.3 Query Engine:要"混合检索"就上它
Vector Set 胜在轻量、零 schema,但如果你要"向量相似 且 租户=acme 且 模型=gpt-x 且 时间 < 某值"这种混合过滤,就得用更重的 Redis Query Engine(原 RediSearch 的进化版)。它支持在索引上同时挂向量字段和标量字段:
# 在 HASH 结构上建索引:一个 384 维 FLOAT32 向量字段 + 两个文本字段
FT.CREATE rqidx ON HASH PREFIX 1 doc: SCHEMA \
embedding VECTOR HNSW 6 DIM 384 TYPE FLOAT32 DISTANCE_METRIC COSINE \
tenant TEXT \
model TEXT
注意 HNSW 6 后面那串数字:它代表 HNSW 的参数个数(这里是 6 个:DIM、TYPE、DISTANCE_METRIC 加上 HNSW 特有的 M、EF_CONSTRUCTION 等,具体视版本)。DISTANCE_METRIC 支持 COSINE、L2、IP。建完索引后,写数据和查询都走标准 Hash 接口,向量以二进制存进去。
2.4 顺手的其它内置能力
- RedisJSON:
JSON.SET doc:1 $ '{"title":"Redis 8","tags":["cache","vector"]}'、JSON.GET doc:1 $.title、JSON.SET doc:1 $.tags[1] '"ai"'——用 JSONPath 做局部读写,不用再把整个文档反序列化。 - 概率结构:
BF.ADD seen q1/BF.EXISTS seen q1(布隆过滤器,去重/防穿透神器)、TOPK.ADD topk q1(Top-K 热点)、TDIGEST.QUANTILE latency 0.99(分位数统计)。做"语义缓存命中率监控""热点 query 统计"时比自己维护 HashMap 稳妥得多。 - Time Series:
TS.ADD metrics 1.0 42、TS.RANGE metrics - +——把缓存命中率、P99 延迟直接当时序数据存,省一套外部监控管线。
三、架构分析:语义缓存层应该怎么设计
回到开头那个痛点。我们要把"向量检索"和"缓存命中"合二为一。核心思路是两道闸门 + 一份向量索引:
用户 query
│
▼
[闸门1:精确命中] ── query 的 hash 命中?──► 直接返回缓存答案(零 LLM 调用)
│ 未命中
▼
[闸门2:语义命中] ── Vector Set KNN Top1 相似度 ≥ 阈值?──► 返回语义近似答案(零 LLM 调用)
│ 未命中
▼
调用 LLM 生成答案 ──► 写回 Vector Set(向量)+ String(答案,带 TTL)
为什么需要两道闸门?
- 精确闸门解决"一模一样的问题再来一遍"。用
SHA256(query)当 key,命中率取决于用户复问率,通常能挡掉 20%~40% 的流量。 - 语义闸门解决"换了个说法但意思一样"。比如"A 和 B 区别"与"对比一下 A、B"语义相同但字面不同,精确哈希挡不住,Vector Set 的余弦相似度能挡住。这是 Redis 8 带来的增量价值——以前你得专门起一套向量库才干这事。
关键架构决策点:
- 向量存在哪:语义缓存用 Vector Set(轻量、零 schema、和缓存同生命周期);如果是"知识库检索 + 业务过滤"的混合检索,用 Query Engine(要标量过滤)。
- 答案存在哪:用普通
String+EX过期,和向量元素通过ans:<elem>这种命名约定关联。这样缓存过期后向量可以异步清理(或容忍脏数据,下次覆盖)。 - 阈值的艺术:语义相似度阈值建议 0.90~0.95,太低会"答非所问"(把不相关问题的答案当缓存返回),太高则退化成精确匹配。最好配合
BF记录"已知误命中"做负反馈。 - 失效策略:
allkeys-lru+maxmemory。缓存本质是可丢的,内存打满时 LRU 淘汰最久未用的,比"内存爆了 OOM"优雅一万倍。
四、代码实战:从 Docker 到可运行语义缓存
4.1 起一个生产取向的 Redis 8
docker run -d --name redis8 \
-p 6379:6379 \
redis:8 \
redis-server \
--io-threads 6 \
--io-threads-do-reads yes \
--maxmemory 8gb \
--maxmemory-policy allkeys-lru \
--save 900 1
--io-threads 6 的讲究:假设你机器有 8 个 vCPU,别设满 8——主线程、后台持久化、IO 线程要抢核,留 1~2 个核给"非 IO 线程"更稳。--io-threads-do-reads yes 让读也走多线程(默认只有写走多线程),高并发读场景收益明显。--maxmemory-policy allkeys-lru 让缓存可安全淘汰。
4.2 Python 语义缓存(Vector Set 版)
下面这段代码可以直接跑:为了让示例不依赖任何外部模型下载,我用了一个可复现的"词频哈希嵌入"做 demo 嵌入器;生产环境把 Embedder 换成真实句向量模型(如 bge-small-zh)即可,接口不变。
import hashlib
import numpy as np
from redis import Redis
class Embedder:
def embed(self, text: str) -> list[float]:
raise NotImplementedError
class HashEmbedder(Embedder):
"""演示用嵌入:把词频映射成定长向量并 L2 归一化。仅用于跑通流程。"""
def __init__(self, dim: int = 128):
self.dim = dim
def embed(self, text: str) -> list[float]:
vec = np.zeros(self.dim, dtype=np.float32)
for tok in text.lower().split():
h = int(hash(tok)) % self.dim
vec[h] += 1.0
norm = np.linalg.norm(vec)
if norm > 0:
vec /= norm
return vec.tolist()
# 生产环境换成真实句向量(取消注释即可):
# from sentence_transformers import SentenceTransformer
# class STEmbedder(Embedder):
# def __init__(self, model="BAAI/bge-small-zh-v1.5"):
# self.m = SentenceTransformer(model)
# def embed(self, text):
# return self.m.encode(text, normalize_embeddings=True).tolist()
class RedisSemanticCache:
def __init__(self, r: Redis, vs_key: str, embedder: Embedder,
sim_threshold: float = 0.92):
self.r = r
self.vs = vs_key
self.embed = embedder
self.thr = sim_threshold
def _vec_str(self, vec: list[float]) -> str:
return ",".join(f"{x:.6f}" for x in vec)
def put(self, query: str, answer: str, ttl: int = 3600) -> str:
vec = self.embed(query)
# 用查询 hash 当元素名,保证幂等:同一个 query 重复 put 不会裂成多条
elem = "q:" + hashlib.sha256(query.encode()).hexdigest()[:16]
self.r.execute_command("VADD", self.vs, "1.0", elem, self._vec_str(vec))
self.r.set(f"ans:{elem}", answer, ex=ttl)
return elem
def get(self, query: str):
vec = self.embed(query)
raw = self.r.execute_command("VSIM", self.vs, "KNN", "1",
"WITHSCORES", self._vec_str(vec))
if not raw:
return None
# 返回形如 [b'q:xxx', b'0.95'],偶发嵌套(VSIM 可能返回多层列表)
flat = raw[0] if isinstance(raw[0], (bytes, str)) else raw[0][0]
score = float(raw[1] if isinstance(raw[1], (bytes, str)) else raw[0][1])
if score < self.thr:
return None
ans = self.r.get(f"ans:{flat.decode() if isinstance(flat, bytes) else flat}")
return ans.decode() if ans else None
# ---- 跑一下 ----
r = Redis(host="localhost", port=6379, decode_responses=False)
cache = RedisSemanticCache(r, "semcache", HashEmbedder())
cache.put("Redis 和 Memcached 有什么区别?", "Redis 支持更丰富的数据结构……", ttl=3600)
cache.put("对比 Redis 与 Memcached", "Redis 支持更丰富的数据结构……", ttl=3600) # 语义相近
hit = cache.get("Redis 对比 Memcached 的差异")
print("命中:", hit) # 很可能命中第二条的语义近似答案
注意 get 里那段 raw[0] if isinstance(...) 的判断——VSIM 在不同客户端版本下返回结构可能是扁平列表或嵌套列表,生产代码要兜底。这是真实踩过的坑,不是炫技。
4.3 带元数据过滤的混合检索(Query Engine 版)
当你的缓存命中后还想"按租户隔离""按模型版本过滤"时,Vector Set 的纯向量查询就不够了,上 Query Engine:
import numpy as np
from redis import Redis
from redis.commands.search.field import VectorField, TextField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.commands.search.query import Query
r = Redis(host="localhost", port=6379)
# 1) 建索引:向量字段 + 两个标量字段
r.ft("rqidx").create_index(
fields=[
VectorField("embedding", "HNSW",
{"TYPE": "FLOAT32", "DIM": 128, "DISTANCE_METRIC": "COSINE"}),
TextField("tenant"),
TextField("model"),
],
definition=IndexDefinition(prefix=["doc:"], index_type=IndexType.HASH),
)
# 2) 批量写入(pipeline 提吞吐)
docs = [
{"id": 1, "vec": np.random.rand(128).astype(np.float32), "tenant": "acme", "model": "gpt-x"},
{"id": 2, "vec": np.random.rand(128).astype(np.float32), "tenant": "acme", "model": "gpt-y"},
{"id": 3, "vec": np.random.rand(128).astype(np.float32), "tenant": "other", "model": "gpt-x"},
]
pipe = r.pipeline()
for d in docs:
pipe.hset(f"doc:{d['id']}", mapping={
"embedding": d["vec"].tobytes(),
"tenant": d["tenant"],
"model": d["model"],
})
pipe.execute()
# 3) 混合查询:租户=acme 且 向量最相近 Top5
query_vec = np.random.rand(128).astype(np.float32).tobytes()
q = (Query("(@tenant:{acme})=>[KNN 5 @embedding $vec]")
.add_param("vec", query_vec)
.dialect(2))
res = r.ft("rqidx").search(q)
for doc in res.docs:
print(doc.id, doc.tenant, doc.model)
(@tenant:{acme})=>[KNN 5 @embedding $vec] 这个语法的精髓在于:先按标量条件过滤候选集,再在候选集内做 KNN。它比"先向量召回再应用层过滤"省得多,也避免了"向量召回的 TopN 全被过滤掉导致空结果"的经典翻车。
4.4 Go 里怎么用 Vector Set
go-redis 目前对 Vector Set 还没有专用的 typed 方法,但用 Do 直接发原始命令毫无压力:
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
vec := "-0.12,0.34,0.08,-0.91,0.45" // 实际场景用你自己的嵌入序列化
// 写入
if err := rdb.Do(ctx, "VADD", "docs", "1.0", "doc:42", vec).Err(); err != nil {
panic(err)
}
// 相似查询
res, err := rdb.Do(ctx, "VSIM", "docs", "KNN", "5", "WITHSCORES", vec).Result()
if err != nil {
panic(err)
}
fmt.Println(res)
}
五、性能优化:把 Redis 8 榨出官方基准里的那部分
5.1 IO 多线程:最容易拿到的免费性能
Redis 长期被吐槽"单线程",但那是命令执行单线程。网络 IO 从 6.0 起就能多线程,Redis 8 把这块打磨得更顺。--io-threads 6 只是第一步,关键是:
- 留核:IO 线程数 = vCPU 数 - 1 或 - 2,别打满。
- 读也多线程:
--io-threads-do-reads yes。默认只读单线程,大 value 高并发读时这里就是瓶颈。 - 命令本身要快:IO 多线程帮不了慢命令(比如
KEYS *、超大HGETALL),该优化命令还得优化。
5.2 向量内存:量化与降维是省钱大头
向量是内存杀手。100 万条 1536 维 float32 向量 ≈ 100w × 1536 × 4B ≈ 5.8 GB,还没算 HNSW 图的边。三个省法:
- 降维(REDUCE):如果业务允许略降精度,把 1536 维降到 256 维,内存直接砍到 1/6,查询也更快。
- int8 量化:Query Engine 支持把 float32 向量存成 int8,内存再砍约 4 倍(精度损失通常可接受)。Vector Set 目前以 float32 为主,选型时注意。
- 访问控制精度:
EF_CONSTRUCTION(建图质量)和查询时的EF越大越准越慢越占内存,按召回率要求反推,别无脑拉满。
5.3 缓存层专属调优
maxmemory-policy allkeys-lru:语义缓存必须可丢,别用noeviction(满了直接写失败)。- 连接池:Python 用
redis.ConnectionPool(max_connections=...),Go 用redis.Options.PoolSize,避免每条请求新建连接。 - Pipeline 批量写:灌向量时用 pipeline,吞吐能差一个数量级。
- 把"监控"也存进 Redis:用
TDIGEST记 P99 延迟、TOPK记热点 query,观测和缓存同生命周期,排障时一个redis-cli就能看,不用切系统。
5.4 什么时候 Redis 8 不该当向量库
诚实地说,Redis 8 不是银弹。以下场景请直接上专用向量库:
- 十亿级以上向量:内存成本会把你吃掉。Milvus / 云托管 Pinecone 这类有磁盘/对象存储分层,成本低一个量级。
- 需要复杂图遍历 / 多向量融合召回:Query Engine 的混合查询够用,但多路召回 + rerank 的重检索链路还是专用引擎更顺手。
- 向量需要强一致持久化 + 跨 AZ 复制:Redis 的 AOF/RDB 对向量的持久化语义要仔细验证,金融级强一致场景慎选。
一句话:Redis 8 适合"缓存 + 中小规模向量检索(百万级)合二为一",不适合"纯大规模向量数据库"。
六、总结与展望
Redis 8 的 "One Redis" 不是营销概念,它确实把过去要拼装四五套模块才能干的事,收敛成了一个 GA 包:Vector Set 让向量检索成为核心数据类型,Query Engine 负责带过滤的混合检索,RedisJSON 兜住文档,概率结构搞定去重和统计。对做 AI 应用、RAG、推荐系统的团队,最大的实际价值是少维护一套系统、少一次网络往返、少一份内存账单——本文那个"两道闸门"语义缓存就是这种收敛最直接的产物。
展望一下:antirez 回归后 Redis 的迭代明显提速,Vector Set 还在快速演进(降维、量化、更接近生产级的持久化语义都会继续补)。未来大概率是"缓存即向量库即文档库"成为中小团队 AI 基础设施的默认形态,专用向量库退守到真正的超大规模场景。
最后留三个落地的自检清单,发布前对着过一遍:
- 语义缓存阈值设了多少?有没有负反馈机制防止"答非所问"的误命中?
maxmemory-policy是不是allkeys-lru?内存打满时缓存能不能优雅淘汰?- 向量维度 / 精度 /
EF是不是按你真实的召回率要求反推的,还是照抄了某篇博客的默认值?
把这三件事想清楚,Redis 8 这把"把向量数据库塞进缓存"的刀,你才算真正握住了刀柄。
参考与延伸:Redis 官方博客 "Introducing Another Era of Fast"(Redis 8 发布说明)、Redis Vector Set 命令参考(VADD/VSIM/VINFO 等)、Redis Query Engine 文档。文中性能数字均为官方基准口径,请务必在自身硬件与数据分布下复测。