说实话,我第一次听说 Redis 能做向量搜索时,第一反应是“这也行”?毕竟大多数人对 Redis 的印象还停留在缓存、队列、分布式锁这些传统战场。但 RediSearch 模块落地之后,Redis 确实把倒排索引、向量索引、JSON 文档这些能力全塞进了同一份内存里。这篇文章我就把 RediSearch 的索引与查询机制拆开讲清楚,再聊聊 MMR 在召回阶段解决结果雷同问题的实际做法,适合正在做语义搜索、推荐召回、RAG 知识库检索,并且不想再单独引入一套向量数据库的同学参考。我会尽量把存储结构、查询链路、参数调优都讲到能直接落地的程度。
1. 为什么选择 Redis 做向量检索
1.1 从缓存到检索:Redis 的边界在扩展
很多团队的架构里,Redis 一直是个“工具人”:热点数据放里面,并发压力挡一挡,锁和队列偶尔也交给它。但 Redis 这些年早就不是单纯的 KV 缓存了,Redis Stack 把 RediSearch、RedisJSON、RedisTimeSeries 这些模块整合进来后,Redis 本质上变成了一个内存多模型数据库。你在同一份数据上既能做结构化查询,又能做全文搜索,还能跑 KNN 向量检索,这意味着很多中小团队可以把检索链路收敛到一个组件上。
我见过不少项目,为了上语义搜索,先接一个向量数据库,再配一套搜索服务,最后还要在中间做数据同步,架构瞬间复杂不少。如果数据量本身可控(百万级别以内的向量),完全可以用 Redis 顶上去,少一套组件就少一批运维事故。当然,这个选择不是无脑的,后面我会专门对比什么时候该用专用向量数据库,什么时候 Redis 绰绰有余。
1.2 对比专用向量数据库的取舍
先看短板,这样才能知道边界在哪。专用向量数据库像 Milvus、Qdrant、Weaviate 在分布式能力、数据持久化、超大向量集(千万级起步)上有明显优势,它们有独立的索引文件、分段合并、多副本机制,适合数据量很大且检索 QPS 很高的场景。Redis 的向量索引全在内存里,单机容量受物理内存限制,分布式扩展需要走 Redis Cluster + 客户端路由,整体能力没那么强。
但 Redis 的长处也很明显:部署简单到极致,docker run 一个 redis-stack 就能跑起来;API 就是 Redis 命令,现有 Redis 客户端直接调用;而且它天生支持混合检索——同一个 Redis Hash 里既有文本字段又有向量字段,查询时可以把全文过滤和 KNN 向量搜索放在一条命令里完成。这一点在实际业务里价值极高,因为有过滤条件的向量检索(比如“只查某个分类下的相似内容”)才是最普遍的诉求,而很多专用向量数据库对过滤+向量联合检索的支持反而磕磕绊绊。
2. RediSearch 核心机制拆解
2.1 倒排索引与向量索引如何共处
RediSearch 的文本检索核心是倒排索引,原理不复杂但很经典:把文档分词后,建立起“词 -> 文档ID列表”的映射,查询时直接按词取列表做交集并集。这种数据结构对精确关键词匹配是降维打击,速度极快,内存开销也可控。而向量索引用的则是 HNSW(Hierarchical Navigable Small World)算法,它是一种基于图的近似最近邻搜索算法,核心思路是给向量建一张多层图:上层稀疏连接,负责快速跳到大致区域;下层密集连接,负责精排找邻居。
RediSearch 的神奇之处在于,它在同一个索引文档里同时维护了这两套结构。一条文档写入时,文本字段进倒排索引,向量字段进 HNSW 图。查询时,你可以只走文本,也可以只走向量,更可以把两条路拧成一股来跑。这个设计让 Redis 在检索场景里既有传统数据库的精确过滤能力,又有向量检索的语义召回能力。
2.2 向量索引的构建参数与内存布局
创建向量索引时,最常碰到的语法是这种:
FT.CREATE idx ON HASH PREFIX 1 doc: SCHEMA title TEXT content TEXT embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE这里HNSW后面的6表示后续跟的参数个数。我见过很多人在这地方翻车,因为如果加了M 40 EF_CONSTRUCTION 200这种可选参数,参数个数就要从 6 改成 8。具体参数含义如下:
TYPE:向量元素类型,一般用 FLOAT32,精度和性能平衡最好。DIM:向量维度,必须和写入的向量长度严格一致,多一个字节都不行。DISTANCE_METRIC:距离度量,可选 COSINE、IP、L2。M:HNSW 图每个节点的最大连接数,默认 16,调大能提升召回率但更吃内存。EF_CONSTRUCTION:建图时的动态候选集大小,默认 200,调大建图更慢但图质量更好。EF_RUNTIME:查询时的候选集大小,默认 10,调大召回率上升但延迟变高。
在实际使用中,我的建议是不要盲目堆参数。文本 Embedding 模型(比如 BGE、OpenAI 那类)生成的向量分布比较稳定,M用 32 左右、EF_CONSTRUCTION用 200、EF_RUNTIME用 50 就已经有不错的检索效果,再往上提收益边际递减,内存倒是会明显涨。
2.3 一次混合检索请求的完整链路
当一条混合查询发出去,RediSearch 内部的处理大致分三步。第一步,解析查询语句,拆出文本条件(比如@title:(redis))和向量参数(KNN 20 @embedding $vector AS score)。第二步,分别执行两个子查询:倒排索引先取出所有 title 含有 redis 的文档 ID 集合,HNSW 索引同时从向量空间里找出距离最近的 N 个候选。第三步,做一个内部 JOIN,取两边的交集,再按向量距离排序返回。
这部分有个关键点:KNN 的过滤不是“先全量向量检索再过滤”,而是“先过滤再在过滤集上做 KNN”。RediSearch 的优化器会把文本条件作为预过滤条件,只有在过滤集上向量量级不够时,才会放宽边界。这个设计让过滤场景下的查询速度不会随着库内向量总量线性退化,而是只跟过滤结果集大小相关。实际测试里,在 50 万条数据上做“分类过滤 + 向量 Top 20”,延迟基本能压在 10 毫秒左右,这对很多在线场景已经完全够用了。
3. MMR 搜索解决召回难题
3.1 召回结果的病灶:相似不等于有用
向量检索最尴尬的问题不是召回不到,而是召回的太“同质化”。举个例子,你在知识库里搜“Redis 内存淘汰策略”,Top 10 结果可能讲的全是 LRU、LFU、maxmemory 这些同一段内容,不同文档只是换了个说法。这不是向量模型有问题,而是语义相似的文本天然聚集在向量空间里,传统的 KNN 只看“和查询的相似度”,完全不管结果之间的冗余。
这个毛病在推荐系统里叫“多样性不足”,在搜索里叫“结果太窄”,在 RAG 里更是致命的——因为多个检索片段讲同一个观点,最后喂给大模型的就是一堆重复信息,上下文空间被白白浪费。这时候就该让 MMR 出场了。
3.2 MMR 的原理与公式拆解
MMR 的全称是 Maximum Marginal Relevance,最大边际相关性。它的核心思想特别直白:每选一个新结果,要看两件事——和查询的相关性要高,和已选结果的相似度要低。最终得分的公式是:
score(d) = λ * sim(d, query) - (1 - λ) * max_sim(d, selected)前半部分是相关项,后半部分是冗余项。λ是调节两者权重的参数,max_sim(d, selected)表示当前候选文档和所有已选文档里最大的相似度,也就是说,如果一个候选和已选文档中的任意一个太像,都会被狠狠扣分。
实际执行是迭代式的:第一轮,因为 selected 为空,冗余项为 0,直接把和 query 最相似的文档选进去;之后每一轮,都从剩余候选里挑score最高的那个,加入结果集,然后重新计算剩余候选的冗余项。整个过程就像“选人进队伍”,一边选最对口的,一边保证队伍里成员各有专长、不要全是同一张脸。
3.3 在 RediSearch 检索链路中落地 MMR
RediSearch 本身并没有内置 MMR 算法,它只解决“怎么高效召回候选”的问题,多样性重排需要我们在应用层做。我的标准做法是两阶段:
第一阶段,用 RediSearch 做宽召回。比如最终想给用户 10 条结果,就召回 50 甚至 100 条,让候选池足够大,MMR 才有足够的“挑选空间”。如果只召回 10 条再重排,那等于矮子堆里拔将军,效果提升非常有限。
第二阶段,在应用层做 MMR 重排。用召回结果向量两两算相似度,按上面的公式迭代选出最终结果集。这一步计算量不大,因为候选就 100 条,两两相似度矩阵一次矩阵乘法就搞定了。
我用 Python 写过一套很小的重排工具,核心代码长这样:
import numpy as np def cosine_sim(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) def mmr_rerank(query_vec, candidate_vecs, candidate_ids, lambda_=0.7, top_n=10): selected = [] remaining = list(range(len(candidate_ids))) rel_scores = np.array([ cosine_sim(query_vec, vec) for vec in candidate_vecs ]) while len(selected) < top_n and remaining: best_idx = None best_score = -float("inf") for i in remaining: if selected: max_dup = max( cosine_sim(candidate_vecs[i], candidate_vecs[j]) for j in selected ) else: max_dup = 0.0 mmr_score = lambda_ * rel_scores[i] - (1 - lambda_) * max_dup if mmr_score > best_score: best_score = mmr_score best_idx = i selected.append(best_idx) remaining.remove(best_idx) return [candidate_ids[i] for i in selected]这段代码看起来简单,但生产环境跑起来有几个坑。一个是λ的取值很敏感,我实测下来0.7到0.8是通用安全区间,业务上如果特别看重多样性可以压到0.5,但再低相关性就失控了。另一个是冗余项的计算,这里用max_sim会惩罚“和任意一个已选太像”的结果,如果你希望惩罚“和整体都像”的结果,可以改成avg_sim,效果会平滑一些。
4. 完整实操:从索引到 MMR 重排
4.1 准备 Redis Stack 环境
因为 RediSearch 不是开源版 Redis 的标准组件,所以我们得用 Redis Stack,一键搞定所有模块。最省事的方式是 Docker:
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest如果不想用 Docker,可以去 Redis 官网按平台下载 Redis Stack 的二进制包,解压后直接redis-server启动即可,windows 也能跑。启动之后记得验证一下模块是否加载成功:
MODULE LIST输出里能看到search和vector相关的模块就算就绪。这一步别省,我碰到过有人装了普通 Redis,然后翻遍文档找向量命令,结果发现根本没有FT.CREATE。
Python 客户端方面,推荐用redis-py,新版本自带搜索模块封装,不需要单独装redisearch-py(那个库已经很久没更新了)。
pip install redis numpy4.2 创建索引并写入向量
接下来是核心代码。我们以一个简化版的文章知识库为例,每条文档包含title、content和一个 768 维的文本向量:
import redis import numpy as np from redis.commands.search.field import TextField, VectorField from redis.commands.search.indexDefinition import IndexDefinition, IndexType from redis.commands.search.query import Query r = redis.Redis(host="localhost", port=6379, decode_responses=False) schema = ( TextField("title", weight=1.0), TextField("content"), VectorField( "embedding", "HNSW", { "TYPE": "FLOAT32", "DIM": 768, "DISTANCE_METRIC": "COSINE", "M": 32, "EF_CONSTRUCTION": 200, }, ), ) definition = IndexDefinition(prefix=["doc:"], index_type=IndexType.HASH) r.ft("doc_idx").create_index(schema, definition=definition)这里有两个容易被坑的点。第一个是decode_responses必须设成False,因为向量的二进制数据(bytes)不能被解码成字符串,否则写入索引时直接报错。第二个是前缀doc:,它决定了哪些 Redis Key 会被自动纳入索引,写入数据的 Key 如果没带这个前缀,索引里永远看不到。
写入数据的代码很简单:
for i, (title, content, embedding) in enumerate(docs): key = f"doc:{i}" r.hset(key, mapping={ "title": title, "content": content, "embedding": np.float32(embedding).tobytes(), })注意np.float32(...).tobytes(),向量的维度、类型必须和索引定义完全一致,768 维的 FLOAT32 就是 3072 字节,多一个字节都不行。
4.3 执行混合检索和 MMR 重排
检索的时候,最常用的写法是这样:
query = ( Query("@title:(redis)=>[KNN 50 @embedding $query_vector AS score]") .sort_by("score") .return_fields("title", "score") .paging(0, 50) .dialect(2) ) params = {"query_vector": np.float32(query_vector).tobytes()} res = r.ft("doc_idx").search(query, query_params=params)不加文本条件时,把查询换成*=>[KNN 50 @embedding $query_vector AS score]就行。dialect(2)必须显式声明,不然解析器不认识向量语法,会给你吐一屏语法错误。返回结果里每个文档的score其实就是距离值,COSINE 距离越小越相似。
拿到 50 条候选后,把它们各自的 embedding 从 Redis 里拉出来(或者直接在内存里缓存),然后跑上面的mmr_rerank,就能得到最终的 Top 10。整个链路拼起来之后,线上库 50 万条向量时,宽召回加 MMR 重排总耗时一般在 30 毫秒以内,其中 20 多毫秒都花在向量检索上,MMR 重排只要几毫秒。这个性能表现,已经足以支撑绝大多数 Web 应用的实时检索需求。
4.4 调参实录:召回率、延迟与内存怎么权衡
参数调优是向量检索避不开的一环。我建议把关注点放在三个指标上:召回率、P99 延迟、内存占用。先说KNN的 K 值,这个值决定了候选池大小。K 太小,MMR 没有发挥空间;K 太大,检索变慢。我在一组 20 万条数据的测试集上跑过,最终效果和候选池 K 的关系基本是这样的:
| 候选池 K | P99 延迟 | 最终结果多样性(人工主观分) | 内存增量 |
|---|---|---|---|
| 10 | 3ms | 一般,结果大量同质 | 无 |
| 50 | 8ms | 好,主题覆盖明显提升 | 无 |
| 200 | 22ms | 很好,但部分结果相关性下降 | 无 |
至于 HNSW 的索引参数,EF_RUNTIME是线上检索时最值得调的一个旋钮,把它从默认 10 调到 50,召回率能肉眼可见地上升,代价只是单次查询多了几毫秒。M参数则建议在建索引前就定好,因为改它需要重建索引,代价比较大。
5. 常见问题与避坑指南
5.1 最容易被新手踩的五个坑
第一个坑是模块版本不一致。老的 Redis 版本只支持 RediSearch 1.x,而向量搜索是 2.x 才支持的特性,很多教程和网上随手抄的命令混着新旧语法,跑起来一头雾水。建议统一使用 Redis 7.x 以上的 Redis Stack,功能完整且社区踩坑记录也多。
第二个坑是dialect版本不对。向量语法=>[KNN ...]是 RediSearch 2.4 以后引入的,必须用DIALECT 2或更高版本,否则报语法错误。这个错误信息还挺有迷惑性,会提示Syntax error at ...,乍一看以为是查询语句写错了。
第三个坑是向量维度不匹配。报错经常是Index dimension is 768 but vector dimension is 512,这种一般是你换了 Embedding 模型,但忘了重建索引。因为键前缀没变,写入还能成功,但索引对不上的时候,查询结果就会异常甚至为空。
第四个坑是超时。向量检索在高并发、大 EF_RUNTIME 下可能变慢,单命令执行超过 Redis 默认timeout时会被客户端直接掐断。线上建议把常见的几个配置调一下:timeout 30(命令执行超时)、client-output-buffer-limit normal 0 0 0(防止大数据写满输出缓冲区)。
第五个坑是内存预估不足。向量索引不像倒排索引那样轻量,HNSW 的占用大概是向量本身的 1.3 到 1.5 倍。768 维 FLOAT32 的单条向量本体就有 3KB,100 万条就是 3GB,加上索引结构轻松超过 4GB。上线前一定要估算好内存,否则 Redis 会在内存压力下开始淘汰数据,索引直接损坏。
5.2 是否该用 Redis 做向量检索的判断标准
分享完避坑,最后给个更实际的判断标准。不是说 Redis 能跑向量检索就什么场景都往上堆,我自己的经验是三个条件同时满足时才选它:向量数据量在几百万条以内、业务对 P99 延迟要求没有那么变态(10 毫秒级别可接受)、不想为了检索引入额外的架构复杂度。
如果你要做的是百亿向量级别的全网相似检索,那还是老老实实用专用向量数据库。如果只是做一个内部知识库的语义搜索、一个推荐系统的召回补充、一个 RAG 管线的候选池,Redis 这个方案真的能让你省掉一个组件,而且维护成本低到可以忽略。
我个人实际用了半年之后,最大的感受是:不要小看 Redis 的扩展能力,但更不要滥用它的扩展能力。向量检索和 MMR 重排这对组合,在中等规模场景里的体验已经非常接近专业搜索引擎,而代价仅仅是往自己熟悉的 Redis 里多写了几条命令。这份收益,值得每一个正在折腾知识库和搜索召回的人尝试一下。