1.1 向量检索与 Redis 的角色:从键值存储到向量数据库
我最早接触 Redis 是拿它做缓存,存一些用户会话、页面片段。那时候 Redis 就是个键值存储,速度快得离谱。后来做推荐系统,需要找相似物品,才开始琢磨向量检索。发现 Redis 也能干这个,不用再单独部署一个向量数据库。这让我挺惊喜的,毕竟技术栈越简单越好维护。
从架构角度看,Redis 的角色在慢慢变化。它不再只是缓存,而是能承担向量搜索的数据库。你可以在 Redis 里存嵌入向量,建索引,做相似度查询。我试过用 RediSearch 模块,几行命令就能创建向量索引。对开发者来说,这意味着原本熟悉的 Redis 命令加上几个新参数,就能玩转向量检索。内存存储带来的低延迟,在线服务里特别有用。
1.2 Redis Vector 核心概念:向量、距离度量、相似度搜索与索引
向量说白了就是一串浮点数,比如 [0.1, 0.2, 0.3]。文本、图片、音频都能通过模型转成这种数字列表。距离度量用来衡量两个向量有多像。余弦相似度看方向,欧氏距离看绝对位置,内积看投影。我在项目里常用 COSINE,文本嵌入归一化后,余弦值很直观。
相似度搜索就是给定一个查询向量,找出库里最接近的几个。暴力对比太慢,需要索引。Redis 支持 FLAT 和 HNSW 两种索引。FLAT 是精确搜索,数据量小的时候用。HNSW 是近似搜索,速度快,适合大规模。我一开始不懂索引,直接暴力扫描,查询延迟高得吓人。建了 HNSW 索引后,响应时间降到毫秒级。
1.3 Redis Stack、Redis Vector Set 与 RediSearch 模块关系
Redis Stack 是个打包好的发行版,里面装了 RediSearch、RedisJSON、RedisTimeSeries 等模块。你安装 Redis Stack,就等于有了向量搜索的能力。RediSearch 模块提供 FT.CREATE 和 FT.SEARCH 命令,可以建向量索引,还能结合元数据过滤。我平时做 RAG 就用这个,把文档向量和来源字段一起存。
Redis Vector Set 是 Redis 8.0 引入的新数据类型。它用 VADD、VSEARCH 这类命令,直接操作向量集合。和 RediSearch 比,Vector Set 更轻量,适合简单相似度搜索。RediSearch 功能更全,支持复杂查询和聚合。我两个都用过,看场景选。如果你已经用了 Redis Stack,RediSearch 是自然选择。如果想试试原生向量类型,Vector Set 值得一玩。
1.4 典型应用场景:语义搜索、推荐系统、RAG、图像检索
语义搜索是我最常做的。用户输入“适合夏天穿的鞋”,传统关键词匹配可能找不到“凉鞋”。把商品描述向量化存 Redis,查询时做相似度搜索,就能召回语义相近的结果。我帮朋友的小店做过一个,转化率提升了不少。
推荐系统也类似。用用户行为向量找相似商品,或者用物品向量找相似物品。RAG 是另一个热门场景。把知识库文档切块、嵌入、存 Redis。用户提问时,先检索相关片段,再交给大模型生成答案。图像检索则是用 CNN 提取特征向量,存 Redis,实现以图搜图。这些场景都依赖低延迟的向量相似度计算,Redis 的内存特性刚好匹配。我在一个多模态项目里,把图片和文本向量都放 Redis,统一检索,效果挺满意。
2.1 Redis Stack 安装与客户端选择:redis-py、Redis Insight
我装 Redis Stack 通常用 Docker,一条命令就拉起来。docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest。8001 端口是 Redis Insight,浏览器打开就能看到数据库里的键和索引。以前我手动编译模块,折腾半天,现在用 Stack 省心多了。本地开发用 Docker,生产环境也差不多,配好持久化和网络就行。
客户端我主要用 redis-py。Python 生态里它最顺手,建连接池、发命令、处理向量字段都很自然。redis-py 从 4.0 版本开始支持 RediSearch 的向量搜索,VectorSimilarityField 这类封装让建索引没那么痛苦。Redis Insight 我用来做可视化调试,查看索引结构、执行 FT.SEARCH、检查向量字段的二进制内容。有时候查询结果不对劲,在 Insight 里跑一遍命令,很快能定位问题。
选客户端看团队习惯。Node.js 项目可以用 node-redis,Java 用 Jedis 或 Lettuce。我自己的项目以 Python 为主,redis-py 加上 Redis Insight 这套组合够用了。安装完记得测试连接,redis-cli ping 返回 PONG 就说明服务正常。别小看这一步,我遇到过端口冲突、防火墙拦截,提前检查省得后面抓狂。
2.2 向量数据表示:维度、数据类型、归一化与嵌入模型选择
向量就是一串浮点数。维度由嵌入模型决定,OpenAI 的 text-embedding-3-small 输出 1536 维,all-MiniLM-L6-v2 是 384 维。维度越高,表达能力越强,内存和计算开销也越大。我选模型时先看任务,语义搜索用 384 或 768 维够用,多模态场景可能上 1024 或 1536。Redis 里存向量用 FLOAT32 类型,每个维度占 4 字节。100 万条 1536 维向量大约 6GB 内存,心里要有数。
归一化是个容易忽略的步骤。如果用 COSINE 距离,最好把向量归一化成单位长度。这样内积就等于余弦相似度,计算更直接。我习惯在嵌入模型输出后做 L2 归一化,vector / np.linalg.norm(vector)。有些模型自带归一化,查文档确认一下。没归一化也能用,但距离度量选 IP 时结果会偏,因为向量长度会影响内积值。
嵌入模型选择看场景和预算。OpenAI 的 API 方便,按 token 收费,适合快速验证。Sentence Transformers 可以本地跑,免费,适合数据敏感或量大。Cohere、Voyage 也有不错的嵌入模型。我试过用同一个查询对比不同模型的召回效果,差异挺明显。选模型时多看看 MTEB 排行榜,再结合自己的数据做小规模测试。维度一旦确定,后面建索引、存数据都按这个来,改起来成本高。
2.3 哈希与 JSON 文档结构设计:字段、向量字段与元数据
Redis 里存向量数据有两种主要结构:HASH 和 JSON。HASH 简单,每个字段扁平存储。向量字段是一个二进制 blob,需要把 float 数组转成 bytes。np.array(vector, dtype=np.float32).tobytes() 得到字节串,存进 HASH 的 embedding 字段。元数据比如 title、category、price 直接作为独立字段。查询时可以用 @category:{鞋类} 这样的过滤条件,配合向量相似度搜索。
JSON 结构适合嵌套数据。JSON.SET doc:1 $ '{"title":"凉鞋","embedding":[...],"tags":["夏季","户外"]}'。RedisJSON 模块支持路径查询和更新,改某个字段不用重写整个文档。我做过一个商品库,分类和属性有层级关系,用 JSON 存更自然。缺点是 JSON 的向量字段也要转成二进制,不能直接存数组。有些客户端库帮你处理转换,redis-py 的 JSON.SET 需要自己序列化。
设计文档结构时想清楚要过滤哪些字段。RediSearch 索引里,TAG 字段用于精确过滤,TEXT 字段用于全文搜索,NUMERIC 用于范围查询。向量字段单独声明为 VECTOR。我一般把常用的过滤维度建成 TAG,比如 brand、color。别把所有字段都塞进索引,索引占内存,更新也慢。一个商品文档通常包含:id、向量、标题、类别、价格、上架时间。够用就行,后面按需加。
2.4 距离度量选择:COSINE、L2、IP 的适用条件与影响
COSINE 衡量两个向量的夹角,关注方向差异。文本嵌入常用它,因为文本语义相似度更多体现在方向上,向量长度受文本长度影响。我处理语义搜索时默认选 COSINE,配合归一化向量。查询向量和文档向量都归一化后,余弦相似度就是内积。Redis 里建索引时指定 DISTANCE_METRIC COSINE,查询返回的分数是 1 减去余弦距离,越接近 1 越相似。
L2 是欧氏距离,衡量向量在空间中的绝对距离。图像特征向量常用 L2,因为像素级别的差异用绝对距离更直观。我做过一个以图搜图的项目,用 ResNet 提取特征,L2 距离效果比 COSINE 好。L2 对向量长度敏感,如果特征向量没归一化,不同图片的向量模长差异会干扰结果。用 L2 时要注意数据分布,必要时先做标准化。
IP 是内积,计算两个向量的点积。如果向量已经归一化,IP 和 COSINE 等价。没归一化时,IP 会偏向模长大的向量。推荐系统里有时用 IP,因为用户向量和物品向量的内积可以表示偏好强度。我选距离度量时先看嵌入模型怎么训练的。模型用余弦相似度训练,就用 COSINE。模型用点积训练,就用 IP。拿不准的话,在验证集上跑一遍,看哪种度量的召回率高。距离度量一旦定了,索引和查询都要一致,改起来得重建索引。
3.1 索引创建流程:FT.CREATE、SCHEMA 与 VECTOR 参数
建索引这个事,我踩过不少坑。一开始以为随便建个索引就能搜,结果发现字段类型和向量参数没对上,查询直接报错。FT.CREATE 是入口,后面跟索引名,然后 ON HASH 或 ON JSON 指定数据类型。SCHEMA 部分定义字段,普通字段用 TEXT、TAG、NUMERIC,向量字段单独用 VECTOR 声明。我习惯先把所有要过滤和返回的字段列出来,再琢磨向量字段怎么配。
向量字段的写法有点讲究。VECTOR 后面跟算法类型,比如 HNSW 或 FLAT,然后是一串参数。TYPE FLOAT32 指定数据类型,DIM 1536 是维度,DISTANCE_METRIC COSINE 选距离度量。这些参数必须和实际存入的向量完全一致,维度差一位都会报错。我试过用 768 维模型建索引,后来换了 1536 维模型,忘了改 DIM,写入时直接失败,排查了半天才想起来。建索引前最好把嵌入模型的输出维度写死在配置里,别靠记忆。
还有一个细节是 PREFIX。默认索引会扫描所有键,数据量大了很浪费。我一般加 PREFIX 1 product: 限定只索引特定前缀的键。这样写入和查询都聚焦在目标数据上,索引体积也小。如果数据是 JSON 格式,ON JSON 后面还可以用 SCORE 指定评分字段,但我用得少,大部分场景默认就行。建完索引用 FT.INFO 看一眼,确认字段和参数都对了,再开始灌数据。
3.2 HNSW 与 FLAT 索引算法:参数、内存与精度权衡
FLAT 和 HNSW 是两种完全不同的思路。FLAT 就是暴力搜索,把查询向量和索引里每个向量都算一遍距离,结果绝对精确。数据量小的时候,比如几万条,FLAT 响应很快,内存占用也低,因为不需要维护额外的图结构。我早期做原型验证就用 FLAT,省心,不用担心近似误差。但数据上了百万级,FLAT 的查询延迟就线性增长,一次搜索可能要几百毫秒甚至更久,线上根本扛不住。
HNSW 是近似最近邻算法,用多层图结构加速搜索。它不需要遍历所有向量,而是沿着图跳着找,速度快很多。代价是结果不保证 100% 精确,但召回率通常能到 95% 以上。HNSW 有几个关键参数:M 控制每个节点的连接数,越大图越密,精度越高,内存也越大。EF_CONSTRUCTION 影响建索引时的搜索范围,值高建得慢但图质量好。EF_RUNTIME 是查询时的参数,可以在查询时临时调,值越大搜索越仔细,延迟也越高。
我选算法看场景。数据量低于 10 万,FLAT 够了,简单可靠。上了 50 万或者对延迟敏感,就上 HNSW。M 我一般设 16 到 32,EF_CONSTRUCTION 设 200 左右,EF_RUNTIME 查询时根据召回要求调,通常 50 到 200 之间。内存方面,HNSW 比 FLAT 多占 30% 到 50%,因为要存图结构。如果内存紧张,可以降低 M,但精度会掉。我做过一个对比测试,M=16 和 M=32 的召回率差 2% 左右,内存差不少,最后选了 16。
3.3 向量写入与更新:HSET、JSON.SET 与批量导入
写入向量最直接的方式是 HSET。把向量转成二进制字节串,存到哈希的某个字段里。Python 里用 np.array(vector, dtype=np.float32).tobytes() 转一下,然后 redis.hset(key, mapping={"embedding": vector_bytes, "title": "..."})。注意字段名要和索引 SCHEMA 里声明的一致,索引里叫 embedding,写入也得叫 embedding,大小写敏感。我犯过这个错,索引建好了但搜不到数据,查了半天发现字段名写成了 vector。
JSON 结构用 JSON.SET,路径用 $ 表示根。向量字段同样要转成二进制,不能直接塞数组。redis.json().set(key, "$", {"title": "...", "embedding": vector_bytes})。更新单个字段可以用 JSON.SET key $.title "新标题",不用重写整个文档。但更新向量字段要小心,改完向量后索引会自动更新,不过 HNSW 的图结构是增量维护的,频繁更新可能影响性能。我一般批量更新,攒一批再写。
批量导入用 pipeline。一条条发命令网络往返太慢,pipeline 把命令打包发过去,吞吐量能翻好几倍。我处理过 100 万条商品向量,用 pipeline 分批,每批 1000 条,几分钟就灌完了。批量写入时注意内存,别一次攒太多,几千条一批比较稳。如果数据量特别大,还可以用 redis-cli --pipe 或者写脚本并行导入。写入完用 FT.INFO 看文档数,确认都进去了。
3.4 相似度查询:KNN、范围查询、混合过滤与排序
KNN 查询是向量搜索的核心。语法是 *=>[KNN 10 @embedding $query_vec AS score]。* 表示查询所有文档,KNN 10 取最近 10 个,@embedding 是向量字段,$query_vec 是查询向量参数,AS score 把距离存到 score 字段。查询向量也要转成二进制,通过 PARAMS 2 query_vec <bytes> 传进去。返回结果默认按距离升序,score 字段是距离值,COSINE 距离下越小越相似。
范围查询用 VECTOR_RANGE,指定一个半径,返回距离小于半径的所有向量。语法类似 @embedding:[VECTOR_RANGE 0.2 $query_vec]。这个适合“找出所有相似度高于某个阈值”的场景,比如去重或者推荐候选集。范围查询的结果数量不固定,可能很多也可能很少。我一般先跑 KNN 看距离分布,再决定半径设多少。半径太小召回不够,太大又太多噪音。
混合过滤是把向量搜索和传统过滤结合起来。比如 @category:{鞋类} @price:[100 500] =>[KNN 20 @embedding $query_vec AS score]。先按类别和价格过滤,再在过滤结果里做向量搜索。过滤字段要在索引里声明为 TAG 或 NUMERIC。这种查询很实用,电商搜索经常这么干。排序方面,默认按向量距离排,也可以用 SORTBY 指定其他字段,但向量搜索场景下很少改。如果既要相似度又要业务排序,可以在应用层做加权融合。
3.5 查询结果解析与分页:距离、分数、元数据返回
查询返回的结果是个嵌套结构。第一层是文档总数,然后每个文档有 ID 和字段列表。字段列表里包含 score 字段,就是距离值。COSINE 距离下,距离 0 表示完全一样,距离 2 表示完全相反。我一般把距离转成相似度展示给用户,similarity = 1 - distance,这样用户看到的是 0 到 1 之间的分数,越接近 1 越相似。L2 距离没有这个转换,直接看距离大小就行。
元数据返回用 RETURN 控制。默认返回所有索引字段,但有时候只需要标题和 ID,用 RETURN 2 title id 限定,减少网络传输。向量字段本身一般不返回,太大了,返回距离和元数据就够了。如果确实需要原始向量,可以单独 HGET 或 JSON.GET 取。分页用 LIMIT offset num,但向量搜索的分页有个坑:KNN 的 k 是整个结果集的大小,LIMIT 只是从结果里截取。如果 k=100,LIMIT 0 10 取前 10 个,LIMIT 10 10 取第 11 到 20 个。但 k 必须大于等于 offset + num,否则拿不到后面的数据。
解析结果时注意字段类型。TAG 字段返回的是字符串数组,NUMERIC 返回数字,TEXT 返回原始文本。我用 redis-py 时,返回的字典结构要逐层剥开,文档 ID 在 id 键,字段在 extra_attributes 或类似结构里。不同客户端版本字段名可能不一样,最好写个适配函数。分页查询时我习惯把 k 设大一点,比如要 10 条就设 k=100,这样翻页也有余量。但 k 太大会增加计算量,根据实际需求权衡。
from langchain_redis import RedisConfig, RedisVectorStore
from langchain_openai import OpenAIEmbeddings
config = RedisConfig(
index_name="product_vectors",
redis_url="redis://localhost:6379",
index_schema={
"vector": {
"algorithm": "HNSW",
"dims": 1536,
"distance_metric": "COSINE",
"datatype": "FLOAT32",
},
"fields": [
{"name": "title", "type": "TEXT"},
{"name": "category", "type": "TAG"},
{"name": "price", "type": "NUMERIC"},
],
},
)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vector_store = RedisVectorStore(embeddings=embeddings, config=config)
FT.CREATE product_idx ON HASH PREFIX 1 product: SCHEMA embedding VECTOR HNSW 6
TYPE FLOAT32
DIM 1536
DISTANCE_METRIC COSINE
M 32
EF_CONSTRUCTION 500
EF_RUNTIME 20
title TEXT category TAG
import redis import numpy as np from openai import OpenAI
r = redis.Redis(host='localhost', port=6379, decode_responses=False) client = OpenAI()
def get_embedding(text):
resp = client.embeddings.create(input=text, model="text-embedding-3-small")
return np.array(resp.data[0].embedding, dtype=np.float32).tobytes()
def index_product(pid, title, desc, category):
text = f"{title}。{desc}。类目:{category}"
r.hset(f"product:{pid}", mapping={
"embedding": get_embedding(text),
"title": title,
"category": category,
"price": 299.0,
})
r.execute_command(
"FT.CREATE", "product_idx", "ON", "HASH", "PREFIX", "1", "product:",
"SCHEMA",
"embedding", "VECTOR", "HNSW", "6",
"TYPE", "FLOAT32", "DIM", "1536", "DISTANCE_METRIC", "COSINE",
"title", "TEXT", "WEIGHT", "1.0",
"category", "TAG",
"price", "NUMERIC"
)