数据库基础体系 · 第 122/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Redis Search 与向量检索:索引、查询、Hybrid 和容量边界
Redis 的基础数据结构解决的是“如何保存和访问值”,而 Redis Search 解决的是“如何按字段内容找到值”。在此基础上,Redis 的向量字段和 KNN 查询又把“按相似度查找”加入了同一个查询引擎。
这几个能力经常被混在一起:
- 索引决定哪些数据可以被检索,以及如何组织检索结构;
- 全文检索按词项、字段和文本匹配规则查找;
- 向量检索按向量距离查找近邻;
- Hybrid Search把结构化过滤、全文匹配和向量召回组合起来;
- 容量边界决定索引能否放入内存、查询是否稳定,以及召回率和延迟能否接受。
本文讨论 Redis Query Engine 的公开稳定语义。历史资料中常称其为 RediSearch;命令仍以 FT. 开头。Redis 核心数据结构本身不等于 Redis Search,实际部署需要使用包含 Query Engine 的 Redis Stack、Redis Cloud 或相应的 Redis Enterprise 能力。
一、先建立模型:数据、索引和查询结果不是同一个东西
Redis Search 至少涉及三层对象:
- 原始数据:Hash 或 JSON 文档;
- 搜索索引:由
FT.CREATE创建,保存字段的倒排索引、数值索引或向量索引; - 查询结果:由
FT.SEARCH返回的文档标识、字段值和计算出来的分数。
例如,下面的 Hash 是原始数据:
doc:1001
title = Redis 向量检索
category = database
body = Redis 可以同时保存结构化字段和向量字段
embedding = <FLOAT32 二进制>
索引不会把这个 Hash 变成另一份可以独立读取的业务文档。索引保存的是用于查找的数据结构,命中后仍然需要返回原始文档中的字段。
因此:
- 删除索引,通常不会自动删除原始 Hash;
- 删除原始 Hash,索引中的对应项也应随索引更新而消失;
- 修改被索引字段,会导致索引项更新;
- 没有索引的字段仍可存储,但不能直接作为 Search 查询字段使用。
1. 索引的范围
FT.CREATE 通过 PREFIX 和数据类型确定索引范围:
FT.CREATE idx:docs
ON HASH
PREFIX 1 doc:
SCHEMA
title TEXT
body TEXT
category TAG
price NUMERIC
embedding VECTOR HNSW 6
TYPE FLOAT32
DIM 3
DISTANCE_METRIC COSINE
这表示:
- 只索引键名以
doc:开头的 Hash; title和body是全文字段;category是标签字段;price是数值字段;embedding是 3 维、FLOAT32格式、使用 HNSW 的向量字段;- 向量相似度使用余弦距离。
索引可以在数据写入之前或之后创建。创建在已有数据之上时,Redis 会对匹配范围内的数据建立索引;创建之后写入或修改匹配文档时,索引也会更新。
PREFIX 不是数据分区的安全边界。一个错误的前缀可能把不应进入同一索引的业务对象全部纳入,造成字段缺失、索引膨胀和查询结果污染。
2. 字段类型决定查询语义
TEXT:全文字段
TEXT 字段使用文本索引。查询通常按词项匹配,而不是简单的字符串包含:
@title:Redis
@body:(向量检索)
@title:(Redis Search)
分词、词项、字段权重和文本评分都属于全文查询语义。不要把 TEXT 当作任意子串搜索;例如要查找完整 URL、版本号或精确状态值,通常更适合使用 TAG 或单独保存规范化字段。
TAG:枚举和精确标签
@category:{database}
@category:{database|search}
TAG 适合状态、租户、类别、语言等有限集合。它不是全文字段,不会对值进行自然语言分词。
标签值中包含查询语法特殊字符时,需要按照 Redis Search 的转义规则处理。生产代码不应直接拼接用户输入,而应使用白名单或可靠的转义函数。
NUMERIC:范围条件
@price:[0 100]
@created_at:[1700000000 +inf]
其中 -inf 和 +inf 表示无穷边界。数值字段可以用于范围过滤,但它不会自动提供全文匹配能力。
VECTOR:向量字段
向量字段的定义至少要明确:
- 算法:
FLAT或HNSW; - 数据类型:例如
FLOAT32; - 维度:
DIM; - 距离度量:例如
COSINE、L2或IP。
维度和数据类型是索引契约。索引声明 DIM 3 TYPE FLOAT32,查询向量和写入向量都必须能按该契约解释;维度不一致通常会导致写入或查询错误,而不是“自动补零”。
二、向量检索的数学含义:查的是距离,不一定是“相似度”
设文档向量为:
查询向量为:
向量检索的任务是从文档集合中找出距离 最小的前 个向量。
1. L2 距离
欧氏距离为:
有些实现可以比较平方距离,因为平方根不会改变排序关系。直觉上,两个向量在几何空间中越近,距离越小。
2. 余弦距离
余弦相似度为:
Redis 查询结果中的 COSINE 通常表现为余弦距离,其关系可理解为:
因此,数值越小越相似。在归一化向量且方向完全相同的情况下,距离接近 0;方向相反时,距离可能接近 2。
这会造成一个常见错误:
SORTBY distance DESC
如果 distance 是余弦距离,这会把最不相似的结果排在前面。KNN 查询通常应使用升序:
SORTBY distance ASC
3. 内积
内积为:
如果业务把内积作为相似度,则通常是分数越大越相似。距离度量和排序方向必须以索引类型及查询返回值的实际语义为准,不能仅根据字段名 score 猜测。
三、FLAT 和 HNSW:精确性与索引成本的选择
1. FLAT:逐个计算,结果精确
FLAT 的基本过程是:
- 读取候选集合中的每个向量;
- 计算它与查询向量的距离;
- 维护距离最小的前 个结果。
若候选数为 ,维度为 ,一次查询的距离计算量大致与:
相关。
FLAT 的优势是结果精确、行为容易理解,适合:
- 数据量较小;
- 强调精确 KNN;
- 需要作为 HNSW 召回率基准;
- 过滤后候选集合已经很小。
它的边界也明确:当每次查询都要扫描大量向量时,延迟会随数据量近似线性增长。
2. HNSW:图上的近似搜索
HNSW 将向量组织成多层近邻图。查询大致经历:
- 从高层图入口开始;
- 沿着更接近查询向量的邻居移动;
- 逐层下降;
- 在底层维护候选集合;
- 返回距离最小的前 个结果。
它不保证像 FLAT 一样返回数学意义上的精确近邻,而是在较低查询成本下获得较高召回率。
影响 HNSW 行为的参数包括:
M:每个节点维护的邻居数量,影响图大小和连接性;EF_CONSTRUCTION:建图时的候选宽度,通常越高,建图成本越大,但图质量可能更好;EF_RUNTIME:查询时的候选宽度,通常越高,查询成本越大,但召回率可能提高。
一个 HNSW 查询的 K 不是“只检查 K 个向量”。K 是最终返回结果数,实际搜索候选数由运行时参数和过滤条件等共同影响。
3. 精确召回率如何验证
不能只观察平均延迟判断 HNSW 是否适合。应使用同一批查询分别执行:
- FLAT 查询,作为精确结果集合;
- HNSW 查询,作为近似结果集合。
对每个查询计算:
例如,精确结果为:
[A, B, C, D, E]
HNSW 返回:
[A, B, D, F, G]
则:
这不是“距离分数相似”就算召回正确,而是看精确 Top-K 中有多少文档被找回来。
四、从建索引到查询:一个可运行的 Hash 向量示例
下面示例使用 Python redis-py 的 Search API。服务端必须提供 Redis Query Engine;客户端版本也应与服务端支持的 Search API 相匹配。
安装依赖:
pip install redis numpy
假设 Redis 运行在本机默认端口。
1. 创建文档和索引
import numpy as np
import redis
from redis.commands.search.field import TextField, TagField, VectorField
from redis.commands.search.index_definition import IndexDefinition, IndexType
r = redis.Redis(host="localhost", port=6379, decode_responses=False)
index_name = "idx:docs"
# 为了让示例可重复,先删除旧索引和示例数据。
# DROPINDEX 默认只删除索引,不删除原始 Hash。
try:
r.ft(index_name).dropindex()
except Exception:
pass
for key in (b"doc:1", b"doc:2", b"doc:3"):
r.delete(key)
fields = [
TextField("title"),
TagField("category"),
VectorField(
"embedding",
"HNSW",
{
"TYPE": "FLOAT32",
"DIM": 3,
"DISTANCE_METRIC": "COSINE",
"M": 16,
"EF_CONSTRUCTION": 100,
"EF_RUNTIME": 10,
},
),
]
r.ft(index_name).create_index(
fields,
definition=IndexDefinition(
prefix=["doc:"],
index_type=IndexType.HASH,
),
)
def vec(values):
return np.asarray(values, dtype=np.float32).tobytes()
r.hset(
"doc:1",
mapping={
"title": "Redis Search 基础",
"category": "database",
"embedding": vec([1.0, 0.0, 0.0]),
},
)
r.hset(
"doc:2",
mapping={
"title": "向量检索入门",
"category": "search",
"embedding": vec([0.9, 0.1, 0.0]),
},
)
r.hset(
"doc:3",
mapping={
"title": "消息流处理",
"category": "database",
"embedding": vec([0.0, 1.0, 0.0]),
},
)
这里的关键点是:
embedding保存的是二进制FLOAT32字节串,而不是 Python 列表的字符串表示;- 每个向量正好包含 3 个
float32; - 索引字段名必须和 Hash 字段名一致;
category使用TAG,因此查询时采用标签语法,而不是全文语法。
2. 执行 KNN 查询
from redis.commands.search.query import Query
query_vector = vec([1.0, 0.0, 0.0])
q = (
Query("*=>[KNN 2 @embedding $query_vec AS distance]")
.sort_by("distance", asc=True)
.return_fields("title", "category", "distance")
.paging(0, 2)
.dialect(2)
)
result = r.ft(index_name).search(
q,
query_params={"query_vec": query_vector},
)
for doc in result.docs:
print(doc.id, doc.title, doc.category, doc.distance)
查询表达式可以拆成:
* => [KNN 2 @embedding $query_vec AS distance]
*:初始过滤条件为全部文档;KNN 2:返回 2 个最近邻;@embedding:使用向量字段;$query_vec:查询参数中的二进制向量;AS distance:把距离暴露为结果字段;DIALECT 2:使用支持该 KNN 语法的查询方言;SORTBY distance ASC:距离越小越优先。
结果中的 distance 是计算结果,不是原始 Hash 中的字段。调用方可以返回它,也可以将其转换成应用层自己的相似度表示,但转换时必须保留原始度量的方向含义。
3. 增加标签过滤
q = (
Query("(@category:{database})=>[KNN 2 @embedding $query_vec AS distance]")
.sort_by("distance", asc=True)
.return_fields("title", "category", "distance")
.paging(0, 2)
.dialect(2)
)
result = r.ft(index_name).search(
q,
query_params={"query_vec": query_vector},
)
这里的 @category:{database} 是过滤条件,KNN 只在符合类别的范围内工作。从语义上看,结果必须同时满足:
并且在过滤集合 中按距离选出前 个:
这与“先取全库 Top-K,再在客户端过滤”不同。后者可能只剩下很少结果,甚至一个也没有。
4. 查询返回零结果时如何判断问题
下面几种情况会导致零结果或错误:
PREFIX与实际键名不匹配;- 文档不是索引声明的数据类型;
- Hash 中字段名与索引字段不一致;
TAG查询没有正确转义;- 查询向量维度或二进制格式不正确;
- 过滤条件确实排除了所有文档;
- 索引仍处于构建或更新阶段;
- 查询方言不支持当前查询表达式。
可以先执行:
FT.INFO idx:docs
FT.SEARCH idx:docs "*" LIMIT 0 10
FT.INFO 用于查看索引的字段和运行状态;FT.SEARCH ... "*" 用于验证索引是否能看到基本文档。诊断时应先去掉向量和过滤条件,再逐层加回,而不是一开始修改多个参数。
五、全文、向量和结构化条件如何组成 Hybrid Search
“Hybrid Search”不是一个唯一的算法名称。在 Redis 中,至少有三种不同层次的组合。
1. 向量检索加结构化过滤
这是最直接的一类 Hybrid:
(@category:{database} @tenant:{t1})
=>[KNN 10 @embedding $vec AS distance]
它的含义是:
- 先满足租户和类别等约束;
- 在约束后的候选中执行向量近邻搜索;
- 按距离返回前 10 个结果。
这类查询适合“语义相似,但必须属于指定租户或商品类别”的场景。
注意,过滤条件可能显著改变候选集合大小。即使全库 HNSW 的召回率很高,过滤后的有效候选很少时,结果数量和延迟也可能出现不同表现。生产验证必须使用真实过滤分布,而不是只测无过滤全库查询。
2. 全文条件加向量召回
例如:
(@title:Redis | @body:Redis)
=>[KNN 10 @embedding $vec AS distance]
它表达的是“文本条件成立的文档中,再按向量距离取近邻”。这不是自动把 BM25 分数和向量距离加权相加,而是把全文条件作为候选约束。
如果全文条件过于严格,语义上相关但没有匹配词项的文档会被排除;如果全文条件过于宽松,向量搜索仍可能面对很大的候选集合。
3. 两路召回,再做融合和重排
更常见的搜索系统做法是分别获得两路结果:
- 文本检索得到
TextTopN; - 向量检索得到
VectorTopN; - 合并候选;
- 去重;
- 进行融合或重排;
- 最后返回前 个结果。
这样做的原因是两种分数通常不可直接比较:
- BM25 分数的范围受词频、文档长度和查询词影响;
- 余弦距离通常越小越好;
- 内积可能越大越好;
- 不同查询之间分数分布也不稳定。
一种不依赖分数尺度的融合方法是 Reciprocal Rank Fusion,简称 RRF:
其中:
- 是文档;
- 是不同召回列表;
- 是文档在列表 中的名次;
- 是平滑常数,常用一个固定正整数,例如 60。
假设:
文本结果:A, C, B, D
向量结果:B, A, E, D
取 :
- A:文本第 1,向量第 2
- B:文本第 3,向量第 1
- D:文本第 4,向量第 4
A 和 B 会排在 D 前面。RRF 不需要把 BM25 分数和余弦距离强行归一化,适合先验证两路召回的互补性。
如果需要更复杂的结果质量,可以在融合后进行重排。重排输入通常包括标题、摘要、权限过滤结果和向量距离,而不是把所有原文直接交给重排模型。无论使用 Redis 内部查询还是应用层融合,都必须在融合前完成租户、权限和文档状态过滤,不能把权限判断推迟到结果展示阶段。
六、Hash 与 JSON 的选择
Redis Search 可以索引 Hash,也可以索引 JSON。
Hash 的特点
Hash 适合:
- 字段结构稳定;
- 文档较扁平;
- 业务主要按固定字段查询;
- 希望用简单的
HSET更新。
索引定义直接使用字段名:
SCHEMA title TEXT category TAG embedding VECTOR ...
JSON 的特点
JSON 适合:
- 文档存在嵌套对象或数组;
- 需要保存较完整的文档结构;
- 需要通过 JSONPath 将嵌套值映射为索引字段。
示意定义:
FT.CREATE idx:json
ON JSON
PREFIX 1 item:
SCHEMA
$.title AS title TEXT
$.category AS category TAG
$.embedding AS embedding VECTOR HNSW 6
TYPE FLOAT32 DIM 3 DISTANCE_METRIC COSINE
AS 后的别名是查询字段名。查询使用 @title、@category 和 @embedding,而不是直接把 JSONPath 写入常规字段查询中。
JSON 的便利也伴随更新成本:一次嵌套路径更新可能引起相关索引项重建;大型 JSON 文档还会增加原始数据和索引维护开销。向量字段应尽量保持明确、稳定的路径,避免同一文档中存在多个含义不同但维度相同的向量。
七、事务、并发和故障路径
1. 单条 Redis 命令的原子执行
Redis 命令在服务端按顺序执行。一个 HSET 命令不会被另一个客户端命令插入中间。对于包含索引字段的写入,业务上应把原始字段更新视为会触发索引更新的一个操作。
但是,“命令原子执行”不等于“多个命令自动组成事务”。
下面的操作可能产生中间状态:
HSET doc:1 title "新标题"
HSET doc:1 embedding <new-vector>
如果两个命令之间有查询,查询可能看到第一条更新后的状态。需要多个字段一起改变时,可以使用 MULTI/EXEC:
MULTI
HSET doc:1 title "新标题"
HSET doc:1 embedding <new-vector>
EXEC
MULTI/EXEC 以事务批次顺序执行,但 Redis 事务没有传统数据库式的回滚机制;命令错误不会把已经执行的前序命令恢复。客户端还应处理网络断开、超时和重试带来的不确定性。
2. 向量写入的失败表现
常见错误包括:
- 向量字节长度与
DIM * sizeof(TYPE)不匹配; - 使用了
float64,但索引声明为FLOAT32; - 写入了文本形式的数组,而不是二进制浮点字节;
- 查询参数名称与
$query_vec不一致; - 查询时没有使用支持 KNN 语法的方言。
这些问题应在应用层写入前校验。以 FLOAT32、3 维为例,向量有效载荷应为:
这只是数据载荷长度,不包含 Hash、JSON、键名、索引结构和内存分配器开销。
3. 索引删除的风险
FT.DROPINDEX idx:docs
默认删除索引结构,但保留原始文档。带有删除文档选项的形式会连同索引关联的数据一起删除,使用前必须确认命令语义和数据类型。
在生产环境中,删除索引前应至少验证:
FT.INFO idx:docs
FT.SEARCH idx:docs "*" LIMIT 0 1
并确认已有备份或可重建方案。删除后重新建 HNSW 可能消耗大量 CPU、内存和时间;如果服务仍承接写入或查询,还要评估重建期间的资源竞争。
八、容量边界:真正占内存的不只是向量数组
1. 向量原始载荷的下界
如果有 条向量、每条 维、每个元素占 字节,则仅向量载荷约为:
例如:
FLOAT32,
则:
约为 30.72 GB 十进制,或约 28.6 GiB。
这还没有计算:
- Hash 或 JSON 原始文档;
- Redis key 和对象开销;
- HNSW 图的邻接关系;
- TEXT 倒排索引;
- TAG 和 NUMERIC 索引;
- 压缩、内存碎片和分配器开销;
- 副本和故障转移副本;
- 集群节点的额外管理开销。
因此,用“向量维度乘数据量”估算出来的只是下界,不能直接作为实例规格。
2. FLAT 和 HNSW 的容量差异
FLAT 主要增加向量存储和索引管理开销,查询工作量随候选数线性增长。
HNSW 还需要保存图结构。图结构的实际内存取决于实现、参数、层级分布和分配器,不能用一个固定的“每条向量增加多少字节”在所有版本和配置上准确推导。M 越大,通常图连接越多,内存和建图成本也越高。
容量评估应采用实测:
- 用目标维度和数据类型写入代表性数据;
- 建立与生产相同的字段索引;
- 使用
INFO MEMORY、MEMORY USAGE和FT.INFO观察; - 分别测试空文档、完整文档、单向量和多字段索引;
- 将副本、故障切换和重建余量纳入预算。
3. 过滤并不总是降低成本
直觉上,加入 @tenant:{t1} 后候选变少,查询应该更快。但实际成本还取决于:
- 过滤字段的选择性;
- HNSW 图上是否容易访问到有效候选;
- 为达到 K 个有效结果需要扩展多少候选;
- 过滤是否在正确的查询阶段生效;
- 分片部署时数据和过滤条件如何分布。
如果过滤条件几乎匹配全库,它不会带来明显收益;如果过滤条件极窄,可能找不到足够的有效近邻,或者需要更多搜索工作才能补足结果数。
4. K、分页和结果规模
向量查询的 K 是召回数量,不应直接设置成最终页面大小。例如页面只展示 20 条,但 Hybrid 检索可能需要:
- 文本召回 100 条;
- 向量召回 100 条;
- 合并去重后重排;
- 最终展示 20 条。
如果直接把两路 K 都设成 20,融合阶段几乎没有候选余量,容易造成某一路结果被另一条路完全覆盖。
另一方面,盲目增大 K 会增加排序、网络传输和应用层融合成本。RETURN 字段也应只返回真正需要的内容;大型 JSON 原文不应在每次召回时全部传输。
九、查询方言和版本边界
Redis Search 的查询语法存在方言概念。向量 KNN 查询、参数绑定和部分查询组合需要使用对应的查询方言;示例中显式使用:
DIALECT 2
客户端 API 中通常对应:
Query(...).dialect(2)
不要假设“服务端升级后所有客户端默认方言都会自动改变”。升级 Redis Query Engine 时,应同时验证:
FT.CREATE的向量字段语法;FT.SEARCH的 KNN 表达式;PARAMS二进制参数传递;- JSON 路径字段映射;
- HNSW 参数名称;
- 集群模式下的 Search 命令支持;
- 客户端库对新语法的封装。
某些向量算法、压缩方式或专用索引能力可能只在特定 Redis 产品、版本或发行版中提供。不能因为其他向量数据库存在某个参数,就推断 Redis 的同名参数具有相同语义。上线前应以目标部署版本的命令文档和 FT.INFO 实际输出为准。
十、生产诊断:从“没有结果”到“结果不对”
情况一:索引查不到任何文档
按以下顺序检查:
FT.INFO idx:docs
FT.SEARCH idx:docs "*" LIMIT 0 10
然后确认:
- 键名是否匹配
PREFIX; - 文档类型是否为 Hash 或 JSON;
- 字段是否存在;
- 索引创建时是否已完成;
- 是否把 Hash 字段名误写成 JSONPath;
- 查询中是否错误使用了
TAG、TEXT或NUMERIC语法。
情况二:向量查询报维度或参数错误
检查:
- 写入向量和查询向量是否都是
FLOAT32; - 字节长度是否等于
DIM * 4; - 参数名是否一致;
- 是否通过
PARAMS或客户端query_params传递; - 是否设置了正确方言。
情况三:结果看起来“不相似”
先不要立即调整 HNSW 参数:
- 用 FLAT 索引对同一数据执行精确查询;
- 检查向量生成模型、归一化方式和版本;
- 检查距离度量是否与 embedding 模型约定一致;
- 确认排序是距离升序还是相似度降序;
- 再用 FLAT 与 HNSW 计算
Recall@K; - 最后才评估
EF_RUNTIME、M等参数。
如果 FLAT 本身结果不符合预期,问题通常不在 HNSW,而在向量生成、字段映射或距离定义。
情况四:查询延迟周期性升高
应区分:
- 查询计算时间;
- 网络传输时间;
- 索引构建或更新时间;
- 后台持久化、复制或故障转移带来的资源竞争;
- 应用层重排时间。
重点观察内存是否接近上限、HNSW 参数是否过大、过滤条件是否失去选择性,以及是否返回了过多字段。只提高查询超时时间通常不能解决容量或候选规模问题。
十一、如何在几个边界之间做取舍
可以用下面的决策逻辑,而不是先选择一个固定参数:
- 需要精确结果、数据量小或候选过滤后很少:优先考虑 FLAT;
- 需要较大规模、可接受近似结果:考虑 HNSW;
- 需要强权限和租户隔离:把过滤条件放入 Search 查询,而不是只在客户端过滤;
- 需要全文和语义互补:采用两路召回并在应用层融合;
- 需要稳定容量:按原始数据、字段索引、向量索引、副本和重建余量共同估算;
- 需要可验证质量:用 FLAT 建立 Recall@K 基线,并在真实查询分布上比较;
- 需要长期升级:固定服务端、客户端、查询方言和索引定义的兼容测试。
Redis Search 的价值不在于把所有搜索问题都变成一个命令,而在于它能让原始文档、结构化过滤、全文倒排和向量近邻在同一个数据服务中协同工作。真正决定系统质量的,是明确每种索引的语义、知道 Hybrid 是过滤还是融合,并把近似检索的召回率、内存和故障恢复成本测量出来。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Redis 过期与 Keyspace 通知:惰性删除、主动删除、事件和可靠性
- 下一篇:Redis 监控与延迟诊断:Slowlog、Latency、内存、复制和热点
- 延伸:Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
- 延伸:混合检索与 RAG 数据层:全文、向量、融合、重排和引用
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论