标签: ann

  • FalkorDB 向量检索踩坑:为什么 db.idx.vector.queryNodes 就是不工作?

    在用 FalkorDB(一个兼容 Redis 协议的图数据库)做 GraphRAG 或语义检索时,我们经常想用它自带的原生向量检索能力,也就是这个 API:

    CALL db.idx.vector.queryNodes('Entity', 'embedding', 10, vecf32($query_vec))
    

    理想很美好:一条 Cypher 就能拿到「和查询向量最相似的 10 个节点」,底层走的是高效的近似最近邻(ANN)检索。

    但很多人第一次用的时候会发现:要么直接报错,要么返回空结果,要么退化成慢得离谱的全表扫描。明明数据都写进去了,为什么就是不工作?

    这篇文章我们就来把 db.idx.vector.queryNodes 能正常工作的两个必要条件讲清楚,再拆解几个最容易踩的坑。

    一、先看结论:两个必要条件缺一不可

    要让原生向量检索真正生效,必须同时满足两点:

    1. embedding 数据是以原生 vector 类型存储的(用 vecf32() 这类函数转换过的向量)。
    2. 在对应属性上创建了向量索引(vector index)

    这两点是「与」的关系,不是「或」。少了任何一个,db.idx.vector.queryNodes 都不会按我们期望的方式工作。

    我们可以打个比方:

    • 条件一(vector 类型)好比「书里的内容确实是按拼音顺序排好的」。
    • 条件二(vector index)好比「书前面有一份拼音目录」。

    只有内容本身有序、又有目录,我们才能翻目录快速定位。如果内容根本不是按拼音排的,那目录就是假的;如果有序但没目录,那还是得一页页翻。两者缺一,”快速查找”都无从谈起。

    下面我们分别说清楚这两个条件,以及为什么它们缺一不可。

    二、条件一:数据必须是原生 vector 类型

    FalkorDB 里有个很关键、但又很容易被忽略的区别:「一串数字」和「一个向量」在存储层面是完全不同的东西。

    什么才算 vector 类型

    在写入的时候,我们必须用 vecf32() 把数组显式转换成向量类型:

    CREATE (:Entity {name: 'Alice', embedding: vecf32([0.1, 0.2, 0.3, 0.4])})
    

    注意这里的 vecf32(...)。它把普通数组转成了 FalkorDB 内部的 32 位浮点向量类型。只有经过这一步,这个属性才是「真正的向量」,向量索引和 ANN 检索才认得它。

    误区一:embedding 是普通 List,不是 vector 类型

    这是最常见的坑。很多写入代码是这样的:

    # 反例:直接把 4096 维数组写进去
    graph.query(
        "MATCH (n:entities {id: $id}) SET n.embedding = $vec",
        {"id": doc_id, "vec": embedding_list},  # embedding_list 是 list[float]
    )
    

    embedding_list 是一个 4096 维的 Python list,通过 Redis / Cypher 传进去后,FalkorDB 把它存成原生 List 类型

    问题在于:

    • List 看起来能存下所有浮点数,功能上”没报错”;
    • 但向量索引不会收录 List 类型的属性
    • 于是 db.idx.vector.queryNodes 要么返回空,要么因为索引里没有条目而查不到目标节点。

    正确做法是在 Cypher 里用 vecf32() 包一层:

    # 正确
    graph.query(
        "MATCH (n:entities {id: $id}) SET n.embedding = vecf32($vec)",
        {"id": doc_id, "vec": embedding_list},
    )
    

    判别小技巧:可以用 RETURN typeof(n.embedding) 检查属性类型。如果返回的不是向量类型,而是数组类型,说明我们踩了这个坑。

    误区二:embedding 是 string,不是 vector 类型

    第二个常见问题:向量被序列化成字符串再存进去。这在跨系统传输、JSON 序列化时特别容易发生:

    # 反例:把向量 JSON 序列化成字符串存储
    import json
    graph.query(
        "MATCH (n:entities {id: $id}) SET n.embedding = $vec",
        {"id": doc_id, "vec": json.dumps(embedding_list)},  # 变成了 "[0.1, 0.2, ...]"
    )
    

    此时 n.embedding 是一个 string,内容是 "[0.1, 0.2, ...]"

    后果和误区一类似,甚至更隐蔽:

    • 字符串完全无法被向量索引识别;
    • 如果后续代码还需要读回向量做手工相似度计算,就得先 json.loads() 反序列化,多一层开销;
    • 更糟的是,一旦一部分数据是 string、一部分是 vector,问题会很难排查。

    根因通常是:数据在某个环节被 JSON 序列化(比如经过某个 API、缓存层、或错误的 ORM 映射),到了写库时忘了反序列化 + vecf32()

    正确做法是保证传入 Cypher 的是原始浮点数组,并用 vecf32() 转换:

    # 正确:先确保是数组,再 vecf32()
    vec = json.loads(raw) if isinstance(raw, str) else raw
    graph.query(
        "MATCH (n:entities {id: $id}) SET n.embedding = vecf32($vec)",
        {"id": doc_id, "vec": vec},
    )
    

    怎么确认自己存对了

    判断真假的关键,是看类型而不是看长相。我们可以用 Cypher 把属性的类型打出来确认:

    MATCH (n:Entity {name: 'Alice'})
    RETURN n.embedding, typeof(n.embedding)
    

    如果返回的类型是 Vectorf32,说明存对了;如果是 Array(List)或 String,那就是踩了上面的坑。

    这里有个很值得强调的点:普通 List 和 vector 打印出来几乎一模一样,都是 [0.1, 0.2, ...] 这种样子。所以肉眼看数据是骗不了自己的,必须看类型。很多人排查半天没头绪,就是因为一直盯着「值」看,而没去看「类型」。

    三、条件二:必须在属性上创建向量索引

    假设我们已经把 embedding 正确存成了 vector 类型,是不是就能查了?还不行。我们还需要为这个属性显式创建向量索引:

    CREATE VECTOR INDEX FOR (n:Entity) ON (n.embedding)
    OPTIONS {dimension: 4096, similarityFunction: 'cosine'}
    

    这里有几个参数要特别注意:

    • dimension:必须和我们实际写入的向量维度完全一致。如果我们的模型输出是 4096 维,这里就得写 4096。维度对不上,索引要么建不成功,要么查询时匹配不上。
    • similarityFunction:相似度函数,常见的是 cosine(余弦)或 euclidean(欧氏距离)。这个要和我们检索时的语义一致——如果 embedding 是为余弦相似度训练的,就该用 cosine

    为什么没有索引也「能查」,但等于没用

    这里有个特别容易让人误判的现象:即使没建向量索引,有些写法下查询也不会直接报错,甚至能返回结果。这会让我们误以为「一切正常」。

    但真相是:没有向量索引时,db.idx.vector.queryNodes 这个原生 ANN 入口根本用不了;就算我们改用别的方式(比如手动算距离再排序)勉强能查,走的也是全量线性扫描——把每个节点的向量都拿出来算一遍距离,再排序取 Top-K。

    在几百个节点的玩具数据集上,这种全扫描感觉不出慢。可一旦数据涨到几十万、上百万节点,每次查询都要遍历所有向量,延迟会直接爆炸。我们本来指望的 ANN「近似最近邻、亚线性复杂度」的优势,一点都没享受到。

    所以「能返回结果」和「向量检索生效」是两回事。真正生效的标志,是 db.idx.vector.queryNodes 能走索引,享受到 ANN 的加速。

    四、把两个条件串起来:一个完整的正确流程

    我们把整个正确的链路完整走一遍,方便对照检查:

    第一步,建索引(可以先建,也可以数据写完再建):

    CREATE VECTOR INDEX FOR (n:Entity) ON (n.embedding)
    OPTIONS {dimension: 4096, similarityFunction: 'cosine'}
    

    第二步,写入数据时用 vecf32() 转成 vector 类型:

    CREATE (:Entity {name: 'Alice', embedding: vecf32($vec_4096)})
    

    第三步,用原生 API 做检索:

    CALL db.idx.vector.queryNodes('Entity', 'embedding', 10, vecf32($query_vec))
    YIELD node, score
    RETURN node.name, score
    ORDER BY score
    

    注意查询向量本身也要用 vecf32() 包一层——查询侧和存储侧的类型必须对齐。

    只要这三步都对,我们就能享受到真正的原生 ANN 检索了。

    五、排查清单:当 queryNodes 不工作时

    如果检索出问题,我们可以按下面这个顺序逐项排查,基本能定位到绝大多数情况:

    1. 查类型,别查值。typeof(n.embedding) 确认属性是不是 Vectorf32。是 ArrayString 就说明写入时没用 vecf32(),或者数据在导入时被序列化成了别的类型。
    2. 确认索引真的建成功了。db.indexes 或对应命令列出所有索引,看目标属性上是不是真有一个 vector index。
    3. 核对维度。 索引声明的 dimension 必须和实际写入的向量维度一致。4096 维的向量配了个 1536 维的索引,肯定对不上。
    4. 核对相似度函数。 检索语义要和 similarityFunction 一致,别拿欧氏距离的索引去做余弦检索。
    5. 确认查询向量也转了类型。 查询侧传进去的向量也要经过 vecf32()

    这五步里,第 1 步是最高频的坑。因为普通 List、string 和 vector 打印出来长得几乎一样,只有看类型才能戳破伪装。

    六、总结

    FalkorDB 的原生向量检索 db.idx.vector.queryNodes 要工作,本质就是两个必要条件,缺一不可:

    • 数据是真正的 vector 类型(用 vecf32() 转过),而不是长得像向量的普通 List 或 string。
    • 属性上建了向量索引,且维度、相似度函数都对得上。

    最容易让人栽跟头的地方,是「数据看起来没问题」这种错觉:List、string 和 vector 打印出来几乎无法区分,所以我们排查时一定要看类型、不要看值。同时也要记住,「查询能返回结果」不等于「向量索引生效」——只有走了索引的 ANN 检索,才能在大数据量下真正跑得快。

    把这两个条件和这几个误区记牢,我们在 FalkorDB 上做向量检索时就能少踩很多坑。

    如果觉得这篇文章对你有帮助,欢迎点赞、收藏加关注。后续持续分享更多有价值的内容。你的支持是我创作的最大动力!