前阵子在给一个知识库问答系统做召回层优化,发现了一个挺反直觉的事实:团队里常被当成“缓存工具”的 Redis,其实早就内置了向量检索能力,装好 RediSearch 模块之后,连专门的向量数据库都不用单独部署。更让我意外的是,单纯 KNN 向量召回在最常见的“相似问题去重”场景里会翻车——召回来的结果全都长一个样。最后是 MMR(最大边际相关性)重排序把这个问题彻底解决的。这篇就把 RediSearch 的索引机制、向量查询链路和 MMR 的完整落地过程一次性讲清楚。
1. 为什么 Redis 能承担向量搜索的职责
1.1 先看背景:召回层到底需要什么
做 RAG、推荐系统或者知识库问答时,召回层通常需要解决两个问题:一是从海量内容里找到语义相近的候选,二是保证候选之间有足够的差异。过去大家习惯把向量检索交给 Elasticsearch、Milvus、Faiss 这类专门系统,但它们的部署成本和处理链路的复杂度都不低。而 Redis 作为已经有大量团队在用的基础设施,如果能直接承担向量召回,整个架构会轻很多。
RediSearch 是 Redis 官方维护的模块,它从 2.6 版本开始正式支持向量相似度搜索,到 Redis Stack 时代更是把向量索引和全文索引合并到了统一的查询语法里。简单说,你不再需要把“文本过滤”和“向量召回”拆到两套系统里做,RediSearch 可以在一次查询里同时完成关键词过滤和向量排序。
1.2 RediSearch 到底给 Redis 增加了什么
RediSearch 本质上是在 Redis 的数据结构之上实现了一套反向索引和向量索引引擎。它支持两种核心能力:
- 全文索引:对 Hash 或 JSON 字段建立分词索引,支持布尔查询、聚合、高亮等。
- 向量索引:对特定字段的二进制向量数据建立索引,在查询时执行 KNN 搜索。
这两种能力可以混用,也就是说查询条件里可以既包含“内容属于某分类”这种过滤条件,又包含“向量最接近这条 query”这种语义条件。对于实际业务,这一点非常关键——生产环境很少有纯粹的向量搜索,大多需要带业务属性的过滤。
1.3 安装 RediSearch 模块
RediSearch 有几种启用方式:
- 直接使用 Redis Stack,它把 RediSearch、RedisJSON、RedisBloom 等模块打包在一起,开发环境最省事。
- 在已有 Redis 实例上动态加载模块,命令是
MODULE LOAD /path/to/redisearch.so,适合生产环境分批灰度。 - 用 Docker 启动时挂载模块文件,或者直接使用
redis/redis-stack-server镜像。
我自己第一次跑的时候选了 Redis Stack 镜像,启动三分钟就把索引建起来了。不过如果是已有核心 Redis 集群,一定要先在一个从节点上加载模块跑通压测,再考虑逐步切换,毕竟模块加载对实例的内存模型有一定影响,这个后面细说。
2. RediSearch 向量索引的核心机制
2.1 两种向量索引算法:FLAT 和 HNSW
RediSearch 的向量索引支持FLAT和HNSW两种算法。理解这两者的区别,直接决定了索引构建参数怎么调。
FLAT是暴力扫描。它把向量全部存在连续内存里,查询时逐一计算相似度,再取 TopK。优点是没有构建过程、召回准确率 100%,缺点是查询耗时随数据量线性增长。适合数据量在几万条以下、或者对召回率极其敏感且 QPS 不高的场景。
HNSW(Hierarchical Navigable Small World)是近似最近邻算法。它把向量组织成分层图结构,高层负责快速跳过不相关区域,低层负责精确定位。查询速度快,但索引构建时间较长,且召回是近似的,偶尔会漏掉真正的最近邻。适合数据量大、QPS 要求高的场景。
实际选择时我会看两个指标:数据量是否超过 10 万条、查询并发是否超过每秒几十次。如果两个条件都满足,基本就锁定 HNSW 了。注意 HNSW 有一个特性,它在低维度数据上的优势不如高维度明显,所以在向量维度只有几十个的场景,FLAT 也不见得慢。
2.2 索引定义里的关键参数到底影响什么
创建向量索引的语法大概是这样的:
FT.CREATE idx:doc ON HASH PREFIX 1 doc: SCHEMA title TEXT WEIGHT 1.0 content TEXT WEIGHT 0.8 embedding VECTOR HNSW 6 TYPE FLOAT32 DISTANCE_METRIC COSINE这里的6是M,也就是每个节点最大连接数。M 越大,图连接越密集,召回越准,但内存和构建时间也越高。还有一个EF_CONSTRUCTION参数,控制构建索引时动态列表的大小,默认 40,调大能提高索引质量,但构建时间更长。查询时还有一个EF_RUNTIME参数控制搜索时的探索范围,这个可以在查询请求里临时指定。
TYPE FLOAT32表示向量每个维度的数值类型,FLOAT32 是默认且通用的选择,内存是 FLOAT64 的一半,精度完全够用。DISTANCE_METRIC支持L2、IP、COSINE三种。L2 是欧氏距离,值越小越相似;IP 是内积,适合已经归一化、用内积近似余弦的场景;COSINE 用的最多,它基于余弦相似度,对向量模长不敏感。对大多数文本向量模型(如 OpenAI 的 embedding、BGE 系列)来说,选 COSINE 最省心。
2.3 查询语法和真正的执行链路
一个典型的 KNN 查询长这样:
FT.SEARCH idx:doc "*=>[KNN 20 @embedding $vec AS distance]" PARAMS 2 vec <binary_data> SORTBY distance DIALECT 2关键点是查询里那个$vec参数,它的值必须是二进制格式的向量,而不能是 JSON 数组或 Base64 字符串。很多人第一次查出来结果是空或者报错,基本都是因为向量数据格式不对。
从执行链路来看,RediSearch 做向量查询的流程是:先解析查询语法中的过滤条件(*是匹配所有文档),然后交给向量索引执行 KNN 搜索,得到候选集的相似度分数,再根据SORTBY排序返回 TopK。如果用 HNSW,这一步走的是图的层级遍历;如果用 FLAT,走的是全量扫描。整条链路的瓶颈往往不在索引本身,而在候选集的后续加载——如果每条文档还关联了大量文本字段,返回的数据量会明显拖慢响应时间。所以生产里我习惯建立一个轻量的索引摘要,向量检索只返回文档 ID 和 score,再二次回查详情。
3. 召回难题:为什么 KNN 结果不堪用
3.1 表面召回率高,实际有效信息少
很多团队上线向量搜索后都会遇到一个奇怪的现象:测评召回率不低,但用户明显觉得推荐结果“千篇一律”。比如知识库问答里查“Redis 持久化方式有哪些”,KNN 召回的 10 条结果里 8 条都是“RDB 和 AOF 的区别”,方法、步骤、示例全讲重复了,但漏掉了 Bgsave 触发机制、AOF 重写策略这些真正的信息点。
问题出在 KNN 的目标只是“和 query 相似”,它不关心候选之间是否重复。向量空间里,语义相近的文本天然聚集在一起,如果一个区域密度高,KNN 会把 TopK 全从这片区域里捞出来,其他同样相关但分布在不同区域的文档就被埋没了。这在信息检索领域叫多样性缺失(redundancy),也就是标题里说的“召回难题”。
3.2 为什么不能简单靠调大 TopK 解决
有人会想:那我把召回数量从 20 调到 100,再在应用层做规则去重不就行了。这条路能缓解,但不算真正解决。因为向量索引返回 Top100 的计算成本比 Top20 高不少,而且如果 Top100 里聚集在同一语义簇的比例很高,规则去重后剩下的仍然不够多样。
更麻烦的是,有些场景的冗余不是“完全重复”,而是“角度重复”。比如推荐系统里,两个商品描述不同、品牌不同,但都强调“高性价比”,对用户来说它们在体验上是冗余的。关键词去重对这种语义层冗余无能为力,必须引入一个能同时评估“和 query 的相关性”与“和已选结果的重叠度”的重排序机制。
3.3 重排序:召回之后的必经环节
在成熟搜索系统里,召回只是第一阶段,后面必然接重排序。向量召回负责把百万级内容缩小到百级候选,重排序负责从百级候选里挑出最终展示的十几个。重排的方式有很多,要么用商家规则人工加权,要么用一个轻量级模型打分,要么用今天的主角 MMR。
4. MMR(最大边际相关性)如何权衡相关性和多样性
4.1 MMR 的公式一句话就能看懂
MMR(Maximal Marginal Relevance)是 1998 年 Jaime Carbonell 和 Jade Goldstein 在信息检索领域提出的重排序算法。它的核心思想是贪心地逐步选取结果:每一步都挑一个“既和 query 相关,又和已经选中的结果不那么像”的新结果,这样选出来的集合就同时具备高相关性和多样性。
公式形式上可以写成:
对每个候选文档 Di,计算 MMR 分数 = λ × 相关性(Di, Query) - (1-λ) × 与已选集中最相似文档的相似度
流程大致如下:
- 先从候选集里选一个和 query 最相关的文档,作为初始结果。
- 对剩余每个候选,分别计算它和 query 的相关性,以及它和所有已选结果的相似度上限。
- 用公式加权得到 MMR 分数,挑出当前 MMR 最高的一条加入结果集。
- 重复第二步直到结果数满足需求。
这里的相关性分数和相似度分数都可以直接复用向量距离的倒数和向量余弦相似度。也就是说,KNN 召回的结果天然带有向量,可以直接为 MMR 提供输入。
4.2 Lambda 参数:相关性和多样性的旋钮
公式里的 λ 是核心控制参数,它的含义很直白:
- λ 接近 1:完全偏向相关性,MMR 退化成普通 KNN 排序。
- λ 接近 0:完全偏向多样性,结果可能离 query 很远,只是彼此差异大。
- λ 取 0.6~0.8 之间:兼顾两者,实际业务里用得最多。
具体调法没有银弹。我是先跑一组评测集,把 λ 从 0.5 起步,每档加 0.1,观察“有效信息覆盖率”和“相关性评分”的变化。比如在知识库问答里,我比较关注的是“前 N 条里包含不同知识点的数量”,λ=0.7 时比 λ=0.9 时的知识点覆盖多了 35%,而相关性评分只掉了 5%,这时候就锁定了 0.7。
4.3 MMR 和贪心去重、聚类的区别
很多项目会用简单策略替代 MMR,但效果差很远:
- 按类别打散:先按业务类别不均匀地分配名额,比如每类最多 2 条。实现简单,但类别划分粗糙,“高性价比手机”和“高性价比家电”很可能被归到不同类别,多样性依旧不足。
- 全部聚类后每个簇取一条:这个方案的问题是簇的数量和大小很难拿捏,簇边界本身就是模糊的,而且聚类结果和查询意图无关。
- MMR:直接从语义相似度层面控制冗余,不依赖预定义的类别体系,适合完全靠向量表达语义的场景。
不过 MMR 也不是没缺点。它是贪心算法,每一步只保证当前最优,不保证全局最优;而且每次选完要重新计算剩余候选和已选集的相似度,计算量是 O(N×K),等候选集大了需要做限制。
5. 实操:在 RediSearch 上把 MMR 跑起来
5.1 数据准备和索引创建
先用 Python 示例演示完整链路。假设有一批技术文档,每篇有标题、正文和 embedding 向量。向量维度用 768,模型可以选 BGE 或者 text-embedding-ada-002,这里不纠结模型细节。
import redis import numpy as np r = redis.Redis(host="localhost", port=6379, decode_responses=False) # 创建向量索引 def create_index(): r.execute_command( "FT.CREATE", "idx:doc", "ON", "HASH", "PREFIX", "1", "doc:", "SCHEMA", "title", "TEXT", "WEIGHT", "1.0", "content", "TEXT", "WEIGHT", "0.8", "embedding", "VECTOR", "HNSW", "10", "TYPE", "FLOAT32", "DISTANCE_METRIC", "COSINE", "EF_CONSTRUCTION", "200" )这里EF_CONSTRUCTION调到了 200,比默认 40 高不少。构建时间会变长,但索引质量明显更好,因为 HNSW 在构建时探索的邻居更多。如果文档量只有几万条,可以接受这个成本。
批量写入数据时,向量字段必须用tobytes()转成二进制:
def add_documents(docs): pipe = r.pipeline(transaction=False) for doc_id, doc in docs.items(): pipe.hset(f"doc:{doc_id}", mapping={ "title": doc["title"], "content": doc["content"], "embedding": np.array(doc["embedding"], dtype=np.float32).tobytes(), }) pipe.execute()5.2 用 KNN 召回候选集
查询端先执行一次 KNN 召回,得到 TopN(比如 50)个候选。这里故意把召回数放大,目的是给 MMR 留足筛选空间。假设最终要展示 5 条,召回 50 条再重排,效果比直接 KNN Top5 好非常多。
def knn_search(query_vec, top_n=50): query_bytes = np.array(query_vec, dtype=np.float32).tobytes() res = r.execute_command( "FT.SEARCH", "idx:doc", "*=>[KNN {} @embedding $vec AS distance]".format(top_n), "PARAMS", "2", "vec", query_bytes, "SORTBY", "distance", "RETURN", "1", "title", "DIALECT", "2" ) # 解析 FT.SEARCH 返回结果 docs = [] total = res[0] for i in range(1, len(res), 2): doc_id = res[i] fields = res[i + 1] title = fields[1] if isinstance(fields, list) else fields[b"title"].decode() distance = float(fields[3]) # distance 字段的值 docs.append({"id": doc_id, "title": title, "distance": distance}) return docs这里有个细节:RETURN 1 title只返回标题字段,不要一股脑把正文也拖回来,能省掉大量网络和解析开销。后面对每条候选做去重和详情回查时,再按需加载正文。
5.3 MMR 重排序实现
为了让 MMR 能计算候选之间的相似度,需要拿到每个候选的向量。可以再批量查一次:
def get_vectors(doc_ids): pipe = r.pipeline(transaction=False) for doc_id in doc_ids: pipe.hget(f"doc:{doc_id}", "embedding") vecs = pipe.execute() return [np.frombuffer(v, dtype=np.float32) if v else None for v in vecs]然后实现 MMR:
from numpy.linalg import norm def cosine_sim(a, b): return float(np.dot(a, b) / (norm(a) * norm(b) + 1e-9)) def mmr_rerank(candidates, query_vec, k=5, lambda_param=0.7): query_vec = np.array(query_vec, dtype=np.float32) # 预计算每个候选与 query 的相关性 for cand in candidates: cand["relevance"] = cosine_sim(query_vec, cand["vec"]) cand["mrr"] = cand["relevance"] selected = [] remaining = candidates[:] # 初始选相关性最高的 best = max(remaining, key=lambda c: c["relevance"]) selected.append(best) remaining.remove(best) while len(selected) < k and remaining: for cand in remaining: # 与已选集合里最相似的相似度,作为冗余度惩罚项 max_sim = max(cosine_sim(cand["vec"], s["vec"]) for s in selected) cand["mrr"] = lambda_param * cand["relevance"] - (1 - lambda_param) * max_sim best = max(remaining, key=lambda c: c["mrr"]) selected.append(best) remaining.remove(best) return selected代码不复杂,关键有三点:相关性分数直接复用候选自身的语义向量;冗余度用“和已选集合里最相似的一条”来代表,而不是平均相似度;每次选完必须更新剩余候选的 MMR 分数,因为已选集合变了。
5.4 效果对比和评估
我在一个 2 万篇技术文档的小型数据集上做了对比测试。查询词是“Redis 缓存击穿解决方案”,分别使用 KNN Top5 和 KNN Top50 + MMR Top5 两种策略,然后人工评估“Top5 结果涵盖的知识点数量”。
结果是:
| 策略 | 相关性均分 | 涵盖不同知识点数 | 冗余重复比例 |
|---|---|---|---|
| KNN Top5 | 0.92 | 2 | 60% |
| KNN Top5 后按标题关键词去重 | 0.90 | 3 | 25% |
| KNN Top50 + MMR(λ=0.6) | 0.85 | 5 | 0% |
相关性均分下降不多,但知识点覆盖从 2 个提升到 5 个,冗余比例从 60% 降到 0%。对问答系统和推荐系统来说,覆盖度的提升带来的体验收益远大于相关性的一点点损失。
5.5 候选集大小和 K 值的经验值
MMR 的效果高度依赖候选集大小。候选集太小,比如只有 5 条,MMR 没有筛选余地,效果等同于 KNN;候选集太大,比如 500 条,每轮重排序的 O(N×K) 计算明显变慢。我的经验是:候选集取最终展示量的 8~12 倍,比如展示 5 条就召回 40~60 条;展示 10 条就召回 80~120 条。这个量级下计算耗时都在几十毫秒内,业务上完全可接受。
6. 生产环境里的坑和调整经验
6.1 向量字段二进制格式的坑
RediSearch 只认二进制向量。很多人用redis-py写入时直接传了 Python list,或者传了 JSON 字符串,查询时报错Could not parse vector。写入时要用np.float32先转换再tobytes(),查询时同样要转。另外维度、类型必须和索引定义完全一致,差一个字节都会出问题。
6.2 内存和索引构建参数的取舍
HNSW 的内存消耗不容小觑。M 取 10 时,单条 768 维 FLOAT32 向量的索引内存大约是向量本身的 2 到 3 倍。如果你的数据规模是千万级,内存规划一定要提前做。构建参数方面,EF_CONSTRUCTION从 40 调到 200 后,索引构建时间会有明显增加,但召回准确率能从 90% 提升到 95% 以上。数据量小可以调大;数据量达到百万级以上,建议先跑一小批数据做实验,平衡构建时间和召回率。
6.3 混合查询的重要性和实现
前面提到 RediSearch 支持全文过滤和向量搜索混用。这个能力在 MMR 场景里非常有用。比如知识库文档系统里,用户只想搜某个类别的文档,或者只搜最近 30 天发布的内容,这时候查询前缀就不能用*匹配全部,而要改成带条件:
FT.SEARCH idx:doc "@category:{ai}=>[KNN 50 @embedding $vec AS distance]" PARAMS 2 vec <query_vec> SORTBY distance DIALECT 2注意=>前面是过滤表达式,后面的[KNN ...]是向量搜索算子。RediSearch 的查询优化器会先执行过滤,再在过滤后的集合里做向量搜索,性能比“先全量向量召回再在应用层过滤”好得多。
6.4 什么时候真的不需要 MMR
MMR 不是所有搜索场景的银弹。如果业务本身就是按“最相似”来排序,比如“找相似图片”“找同款商品”,用户要的就是高度相关,冗余不是问题,这时候上 MMR 反而会把最相关的结果挤下去。另外如果候选集本身就已经做了高质量的打散(比如已经有业务规则强约束),MMR 的增益也有限。我自己的判断标准是:上线前问自己三个问题——结果里是否存在大量语义重复?用户是否因为重复信息而流失?多样化之后相关性下降是否能被接受?三个问题有两个“是”,才值得上 MMR。
6.5 扩展:和更大规模检索系统配合
RediSearch 加上 MMR 这套链路,也能作为更大规模系统的召回模块。比如先用 Milvus 或 Faiss 做千万级粗召回,再在 Redis 里存候选集的元信息并执行 MMR 重排。RediSearch 在这里的角色是轻量级重排引擎和内存缓存层,Redis 的优势又回来了。
最后再分享一个我在实际项目里觉得很值的小经验:把 MMR 的 λ 参数做成可配置的,甚至允许在请求级别临时覆盖。因为不同 query 对多样性的需求差异很大——问答场景里用户希望看到多个角度的答案,λ 调低一点;电商场景里用户就想买同类商品,λ 调高一点。一个参数接口,能让搜索体验灵活很多,这也是我在这套链路上收获最大的一个设计。