【过期】向量 & 全文索引数据类型支持 #301
Replies: 9 comments
|
补充下最近调研到的开源方案,先把现有产品的实现路线做个归类,方便后面给 Kiwi 定方向。
我目前的理解是,现有方案大致分成两类:
如果 Kiwi 这边阶段目标是“先把向量支持起来”,我倾向于:
后面如果要补“向量 + 全文 + 条件过滤”的统一查询,再往 Search Layer 演进会更自然。 |
Redis 向量与全文索引相关命令整理从源码看,Redis 现在相关命令主要分两类:
Vector Set 命令https://redis.io/docs/latest/commands/redis-8-0-commands/#vector-set-commands
RediSearch / FT 命令https://redis.io/docs/latest/commands/redis-8-0-commands/#search-commands
Suggestion / Auto-complete 命令
一句话总结:
|
KV 向量相关命令整理从 KV 源码看,它目前没有 Redis 8 的 Search 命令
向量字段定义
示例: 向量查询方式
当前限制
一句话总结: KV 目前的向量命令不是 |
|
下面按三个体系对比:Redis Vector Set、Redis/RediSearch Vector、KV Vector。
最关键的区别可以概括成一句话:
|
KV 中 FT 命令的完整执行流程可以把 KV 的 FT 能力理解成三段链路: 也就是说,FT 模块并不是一个孤立的查询入口。它由三部分共同完成:命令解析、索引维护、查询执行。
1.
|
| KV 类型 | 内容 |
|---|---|
INDEX_META |
记录该索引建立在 HASH 还是 JSON 上 |
PREFIXES |
记录该索引关心哪些 key 前缀,例如 doc: |
FIELD_META |
记录字段类型和参数,例如 vec 是 VECTOR,维度为 3,距离函数为 L2 |
因此,FT.CREATE 的本质不是写入文档,而是建立索引规则和索引元数据。
2. HSET / JSON.SET:写入数据并维护 FT 索引
FT 索引的更新不是通过单独的 FT.ADD 完成的,而是由普通写命令触发。
例如:
HSET doc:1 vec <binary-vector>
执行这条命令时,KV 会判断当前是否存在匹配 doc:1 的 FT 索引。如果存在,会按以下顺序执行:
1. 写入前,读取 doc:1 当前已被索引的字段值;
2. 执行 HSET,写入新的 HASH 数据;
3. 写入后,再读取 doc:1 当前字段值;
4. 对比 old / new;
5. 根据字段类型更新对应索引。
对于 VECTOR 字段,会进入 UpdateHnswVectorIndex:
如果原来存在向量:
删除旧的 HNSW node 和 edge;
如果现在存在向量:
插入新的 HNSW node;
查找近邻;
写入 HNSW edge;
更新 node metadata 和 field metadata。
因此,KV 的 FT 索引维护模式可以概括为:
普通写命令负责更新原始数据;
GlobalIndexer 负责在写命令前后记录变化;
IndexUpdater 负责把字段变化同步到 Search 索引。
对于 vector 来说,最终维护的是一个存储在 RocksDB Search CF 中的 HNSW 图。
3. FT.SEARCH:解析查询、生成计划、执行索引扫描
示例:
FT.SEARCH idx "*=>[KNN 10 @vec $BLOB]" PARAMS 2 BLOB <query-vector> DIALECT 2
执行流程如下:
1. CommandFTSearch::Parse 解析外层参数;
2. ParseRediSearchQuery 解析查询字符串;
3. redis_query::ParseToIR 将查询转换成 IR;
4. IndexManager::Search 调用 GeneratePlan;
5. SemaChecker 校验 index、field 和字段类型;
6. PassManager 将 IR 转换成物理执行计划;
7. ExecutorContext 根据计划创建 executor;
8. executor 读取 Search CF 中的索引数据;
9. ProjectionExecutor 在需要时回表读取 HASH / JSON 字段;
10. DumpQueryResult 将结果组装成 RESP 返回。
例如 KNN 查询会经历如下转换:
RediSearch query string
↓
VectorKnnExpr
↓
HnswVectorFieldKnnScan
↓
HnswVectorFieldKnnScanExecutor
↓
HnswIndex::KnnSearch
↓
返回匹配的 document key
如果是 TAG 查询,则会走 TagFieldScanExecutor;如果是 NUMERIC 查询,则会走 NumericFieldScanExecutor;如果是 VECTOR_RANGE 查询,则会走 HnswVectorFieldRangeScanExecutor。
4. 返回结果的方式
FT 查询结果最终会被转换成 RediSearch 风格的 RESP 结构:
[
total_count,
key1,
[field1, value1, field2, value2],
key2,
[field1, value1]
]
也就是说,查询阶段通常分两步:
先查索引,得到 key;
再按 RETURN 或默认规则读取字段值,组装返回结果。
总结
KV 的 FT 流程可以简化为:
FT.CREATE 创建索引规则;
HSET/JSON.SET 写入数据时自动维护索引;
FT.SEARCH 查询索引并回表读取字段。
从模块职责看:
| 模块 | 职责 |
|---|---|
cmd_search.cc |
FT 命令入口,负责参数解析和调用下层模块 |
index_manager.cc |
管理索引元数据,负责创建索引、生成查询计划、执行搜索 |
indexer.cc |
在普通写命令后维护索引 |
hnsw_indexer.cc |
维护 VECTOR 字段对应的 HNSW 图 |
search_encoding.h |
定义 Search CF 中 key/value 的编码格式 |
plan_executor.cc / executors/ |
根据查询计划执行具体索引扫描 |
核心模型是:
原始数据存储在 HASH / JSON 中;
搜索索引存储在 RocksDB Search CF 中;
FT.CREATE 定义索引;
普通写命令更新索引;
FT.SEARCH 读取索引并返回结果。
KV 向量索引中
|
| 对比项 | ON HASH |
ON JSON |
|---|---|---|
| 原始数据位置 | Hash field | JSON 字段 / JSON path |
| 向量输入格式 | 二进制 double 数组 | JSON 数字数组 |
| 解析方式 | 按 double* 直接读取 |
遍历 JSON array,转换成 std::vector<double> |
| 维度校验 | value.size() == dim * sizeof(double) |
array.size() == dim |
| 可读性 | 不直观,人眼不可读 | 直观,可直接看到 [1.0, 2.0, 3.0] |
| 客户端编码成本 | 较高,需要手动按 double 二进制打包 | 较低,直接写 JSON 数组 |
| 服务端解析成本 | 较低,少一层 JSON path 和数组解析 | 较高,需要 JSON path 查找和数组解析 |
| 最终索引结构 | HNSW node / edge | HNSW node / edge |
| HNSW 行为 | 一样 | 一样 |
ON HASH 的向量格式
如果索引定义为:
FT.CREATE idx ON HASH PREFIX 1 doc: SCHEMA vec VECTOR HNSW 10 TYPE FLOAT64 DIM 3 DISTANCE_METRIC L2
那么 vec 字段必须是二进制 double 数组,而不是字符串形式的数组。
错误理解:
HSET doc:1 vec "[1,2,3]"
正确模型:
HSET doc:1 vec <24 bytes binary double blob>
当 DIM = 3 时,每个 double 占 8 字节,因此字段长度必须是:
3 * sizeof(double) = 24 bytes
KV 会检查字段长度是否等于 dim * sizeof(double),然后按 double 数组读取。
ON JSON 的向量格式
如果索引定义为 JSON,则原始文档可以是:
{
"vec": [1.0, 2.0, 3.0]
}KV 会检查:
1. 字段值必须是 JSON array;
2. array 长度必须等于 DIM;
3. array 中每个元素必须是 number;
4. 解析后转换为 std::vector<double>。
因此,ON JSON 更适合文档型数据,也更方便人工调试;但相比 ON HASH,它多了 JSON path 和数组解析成本。
实现建议
从实现复杂度和当前 KV 的代码路径看,可以优先实现 ON HASH 的向量索引能力,后续再补 ON JSON。
原因是:
| 阶段 | 建议 |
|---|---|
| 第一阶段 | 先实现 ON HASH,要求向量字段使用 dim * sizeof(double) 的二进制格式 |
| 第二阶段 | 再实现 ON JSON,支持从 JSON array / JSON path 中解析向量 |
这样做的好处是:
ON HASH 的输入格式更直接;
可以更快打通 FT.CREATE -> HSET -> HNSW index -> FT.SEARCH 的主链路;
后续 ON JSON 只是在字段提取阶段增加 JSON path 和数组解析,不需要重做底层 HNSW 索引结构。
最终可以概括为:
ON HASH:客户端负责把向量编码成二进制 double,服务端直接读取。
ON JSON:客户端写 JSON 数组,服务端负责解析 JSON 并转换成 double 向量。
进入 HNSW 后,两者没有本质区别。
|
补充一点关于 JSON 类型支持的看法(针对上面 ON HASH vs ON JSON 的分析)。 ON HASH 先行的思路没问题从实现路径看,先 ON HASH 打通 JSON 类型建议排第二阶段,但要明确做JSON 对用户体验是刚需。AI 场景下 embedding 基本都是 Python 列表,直接写 JSON 数组 ON JSON 和 ON HASH 底层走同一套 HNSW,只是字段提取阶段多一层 JSON path 解析,不需要重写索引引擎,风险很低。 建议的分阶段路线
一句话:不是搞不搞 JSON 的问题,是什么时候搞、在哪一层搞。先 HASH 后 JSON,保持底层稳定,Proxy 层只做命令透传不做 JSON 解析,节奏最稳。 |
这次实现了什么这次先实现了两个命令:
当前只支持最小可用范围:
可以把这次实现理解成一条很简单的链路: 也就是说,这一版不是“独立的 vector data type”,而是“先在 HASH 文档上挂一个向量字段,再在 SearchCF 里维护二级索引”。 原理概括1. 创建向量 schema 时做了什么例如: 这条命令会做两件事:
写进去的不是文档数据,而是索引定义本身。当前主要有两类元数据:
2. 向量数据存到哪、存成什么样业务数据本身还是存到原来的 HASH 里,比如: 也就是说:
当前 所以这版 3. 查询时是一个什么流程例如: 当前查询流程可以概括成: 所以当前
SearchCF 编码下面是当前 Common Header: IndexMeta: FieldMeta: FlatVectorEntry: FlatVectorEntry scan prefix: 这里有两个关键点:
这样做的目的是让 RocksDB 可以从同一个 为什么现在没有 custom comparator
现在
这里的 这次也专门补了回归测试,验证默认 comparator 下, 这次顺手解决了什么问题除了把命令打通,这次还修了一个一致性问题:
这样至少能保证:
不会在非法输入下出现半成功状态。 当前局限当前这版是刻意收敛过的,限制主要有这些:
所以这版的定位就是: 后续扩展建议后面如果继续做,我建议按下面顺序往前走:
也就是说,先把“向量存储和检索边界”做稳,再去做“更复杂的查询层”。 |
向量索引路由策略问题与方案讨论
1. 当前发现的问题1.1 现象在多 RocksDB Instance 场景下,向 Hash 中写入向量字段后, 例如:插入 3 个带向量字段的 Hash( 1.2 当前实现的路由方式当前代码中,向量索引相关操作的路由规则如下:
1.3 根因分析问题的根源在于
当
如果恰好 2. 候选解决方案针对上述问题,有三种可行的改进方向:
3. 建议采用的方案:方案 2综合考虑实现复杂度、正确性和搜索性能,建议当前先采用方案 2: 3.1 方案 2 实现后的路由方式
3.2 方案 2 与方案 1 的对比
选择方案 2 的理由:
3.3 方案 2 引入的新问题方案 2 解决了正确性问题,但带来了新的扩展性隐患:
这个问题在百亿级数据量下会比较明显,后续需要单独讨论。 4. 总结
当前建议先采用方案 2,以解决搜索结果缺失的问题。大数据量下的扩展性瓶颈作为后续独立话题进一步讨论。 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
WIP
All reactions