Redis+RediSearch向量检索:KNN召回与MMR重排序实战解析

发布时间:2026/10/7 17:01:49
Redis+RediSearch向量检索:KNN召回与MMR重排序实战解析 前阵子在给一个知识库问答系统做召回层优化发现了一个挺反直觉的事实团队里常被当成“缓存工具”的 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 和 HNSWRediSearch 的向量索引支持FLAT和HNSW两种算法。理解这两者的区别直接决定了索引构建参数怎么调。FLAT是暴力扫描。它把向量全部存在连续内存里查询时逐一计算相似度再取 TopK。优点是没有构建过程、召回准确率 100%缺点是查询耗时随数据量线性增长。适合数据量在几万条以下、或者对召回率极其敏感且 QPS 不高的场景。HNSWHierarchical 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 的公式一句话就能看懂MMRMaximal 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.60.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(hostlocalhost, port6379, decode_responsesFalse) # 创建向量索引 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(transactionFalse) for doc_id, doc in docs.items(): pipe.hset(fdoc:{doc_id}, mapping{ title: doc[title], content: doc[content], embedding: np.array(doc[embedding], dtypenp.float32).tobytes(), }) pipe.execute()5.2 用 KNN 召回候选集查询端先执行一次 KNN 召回得到 TopN比如 50个候选。这里故意把召回数放大目的是给 MMR 留足筛选空间。假设最终要展示 5 条召回 50 条再重排效果比直接 KNN Top5 好非常多。def knn_search(query_vec, top_n50): query_bytes np.array(query_vec, dtypenp.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[btitle].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(transactionFalse) for doc_id in doc_ids: pipe.hget(fdoc:{doc_id}, embedding) vecs pipe.execute() return [np.frombuffer(v, dtypenp.float32) if v else None for v in vecs]然后实现 MMRfrom 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, k5, lambda_param0.7): query_vec np.array(query_vec, dtypenp.float32) # 预计算每个候选与 query 的相关性 for cand in candidates: cand[relevance] cosine_sim(query_vec, cand[vec]) cand[mrr] cand[relevance] selected [] remaining candidates[:] # 初始选相关性最高的 best max(remaining, keylambda 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, keylambda c: c[mrr]) selected.append(best) remaining.remove(best) return selected代码不复杂关键有三点相关性分数直接复用候选自身的语义向量冗余度用“和已选集合里最相似的一条”来代表而不是平均相似度每次选完必须更新剩余候选的 MMR 分数因为已选集合变了。5.4 效果对比和评估我在一个 2 万篇技术文档的小型数据集上做了对比测试。查询词是“Redis 缓存击穿解决方案”分别使用 KNN Top5 和 KNN Top50 MMR Top5 两种策略然后人工评估“Top5 结果涵盖的知识点数量”。结果是策略相关性均分涵盖不同知识点数冗余重复比例KNN Top50.92260%KNN Top5 后按标题关键词去重0.90325%KNN Top50 MMR(λ0.6)0.8550%相关性均分下降不多但知识点覆盖从 2 个提升到 5 个冗余比例从 60% 降到 0%。对问答系统和推荐系统来说覆盖度的提升带来的体验收益远大于相关性的一点点损失。5.5 候选集大小和 K 值的经验值MMR 的效果高度依赖候选集大小。候选集太小比如只有 5 条MMR 没有筛选余地效果等同于 KNN候选集太大比如 500 条每轮重排序的 O(N×K) 计算明显变慢。我的经验是候选集取最终展示量的 812 倍比如展示 5 条就召回 4060 条展示 10 条就召回 80120 条。这个量级下计算耗时都在几十毫秒内业务上完全可接受。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 什么时候真的不需要 MMRMMR 不是所有搜索场景的银弹。如果业务本身就是按“最相似”来排序比如“找相似图片”“找同款商品”用户要的就是高度相关冗余不是问题这时候上 MMR 反而会把最相关的结果挤下去。另外如果候选集本身就已经做了高质量的打散比如已经有业务规则强约束MMR 的增益也有限。我自己的判断标准是上线前问自己三个问题——结果里是否存在大量语义重复用户是否因为重复信息而流失多样化之后相关性下降是否能被接受三个问题有两个“是”才值得上 MMR。6.5 扩展和更大规模检索系统配合RediSearch 加上 MMR 这套链路也能作为更大规模系统的召回模块。比如先用 Milvus 或 Faiss 做千万级粗召回再在 Redis 里存候选集的元信息并执行 MMR 重排。RediSearch 在这里的角色是轻量级重排引擎和内存缓存层Redis 的优势又回来了。最后再分享一个我在实际项目里觉得很值的小经验把 MMR 的 λ 参数做成可配置的甚至允许在请求级别临时覆盖。因为不同 query 对多样性的需求差异很大——问答场景里用户希望看到多个角度的答案λ 调低一点电商场景里用户就想买同类商品λ 调高一点。一个参数接口能让搜索体验灵活很多这也是我在这套链路上收获最大的一个设计。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询