Skip to content

GFQL: Cypher multi-seed hop (WHERE a.id IN [...]) does not use the resident adjacency index; single-seed does #2116

Description

@lmeyerov

Summary

With a resident edge_out_adj index and the default index_policy='use', a Cypher seeded hop uses the index when the seed is a single id but falls back to the O(E) scan when the seeds are an IN list. The multi-seed form is the one the index exists for ("the neighbors of these 50 accounts"), so the fast path is missing exactly where it matters.

Repro (graphistry 0.59.0+320, pandas 2.3.3)

import pandas as pd, graphistry
nodes_df = pd.DataFrame({"id": ["a","b","c","d"], "type": ["person","person","company","company"]})
edges_df = pd.DataFrame({"src": ["a","a","b","c"], "dst": ["b","c","c","d"], "e_type": ["knows","sent","works_at","owns"]})
g = graphistry.edges(edges_df, "src", "dst").nodes(nodes_df, "id").gfql("CREATE GFQL INDEX FOR edge_out_adj")

g.gfql_explain("MATCH (a {id: 'a'})-[e]->(b) RETURN b")
# used_index=True  decision_code='index_selected'  (seeded_typed_hop served, path=index)

g.gfql_explain("MATCH (a)-[e]->(b) WHERE a.id IN ['a','b'] RETURN b")
# used_index=False decision_code='index_path_unavailable'
# decision_reason='index path not applicable -> scan'  chosen_direction='forward'  est_seed_cardinality=2
# steps: indexed_traversal/connected_bindings served=False reason='unsupported_shape'; hop path=scan

WHERE a.id = 'a' behaves like the inline-property form (index_selected). UNWIND [...] AS sid MATCH (a {id: sid}) raises the known multi-source row-lowering limit (#1273), so there is currently no Cypher spelling of a multi-seed indexed hop.

Answers are identical with index_policy='off' in every case; this is a performance gap, not a correctness one.

Related, not duplicates

Where found

While verifying a Cypher example for the index_adjacency docs page actually engages the index before publishing it. The docs will lead with the single-seed form that is proven to engage.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp

Activity

  1. lmeyerov commented on Oct 2, 2026

    @lmeyerov
    ContributorAuthor

    Chased this on master 37d7d9b6a; the index gap is real but it was not where the time went. Same query shape at 100k nodes / 500k edges, pandas, 50 seeds:

    • MATCH (a)-[e]->(b) WHERE a.id IN [50 ids] RETURN b: 4,004 ms, identical under index_policy use / off / force. The native [n({"id": is_in(seeds)}), e_forward(), n()] chain: 7.6 ms. g.hop(seed_df): 0.8 ms.
    • cProfile: 15.6 of 15.7 s (under the profiler) in row/pipeline.py::_gfql_eval_in_expr — IN was evaluated per row × per list element in Python (_gfql_cypher_value_equal called 5,005,803 times). Cost model ≈ 400 ms + 75 ms per list element at 100k rows, which is why IN [2] was already 566 ms.
    • Why single-seed looked indexed and multi-seed not: WHERE a.id IN [...] lowers to an alias prefilter + where_rows, not onto the pattern's filter_dict the way a.id = x does, so connected_bindings declines at its first gate (prefilters present) and the hop runs inside the row pipeline — where maybe_index_hop missed the registry's identity guard because the pipeline tags the edge frame with __gfql_edge_ident_0__ without migrating the index. force only "worked" by rebuilding the CSR per query.
    • Also: with only edge_out_adj created (as in the repro), the single-seed index_selected is the seeded_typed_hop fast path step, which the trace recorder labels path: "index"; the real index seam (destination_return) reports index_missing until node_id is also built. The docs example should create both.

    #2117 fixes the three layers (vectorized IN lane 4,004 → 101 ms; index migrates onto the pipeline's edge frame → 67 ms with the hop step now index; IN [literals] seeds the pattern as is_in → 19.7 ms with indexes, 62 ms without). Still open after it: the indexed bindings kernel declines a membership seed, so the unseeded middle runs once before the bindings path — that is the piece that closes this issue as filed, and it is next. Pre-existing and separate: WHERE NOT (b.id IN [...]) raises "AST evaluator unsupported".

  2. lmeyerov commented on Oct 3, 2026

    @lmeyerov
    ContributorAuthor

    Attribution correction, from the #2117 investigation: the observable here (index_path_unavailable → scan for WHERE a.id IN [...]) was accurate, but the index miss was only one of four layers, and not the dominant cost. The row pipeline evaluated IN per row × per list element in Python (~5M calls for 50 seeds on 100k nodes / 500k edges, ~4 s), the IN predicate lowered as a post-join prefilter instead of seeding the pattern, the row pipeline's edge-frame copy broke the index registry's identity guard, and the bindings kernel declined membership seeds.

    #2117 fixes all four (4,004 ms → 10.5 ms indexed / 62 ms unindexed on pandas; cuDF 19 ms, polars 5.4 ms). Leaving this open for #2117 to close.

  3. lmeyerov commented on Oct 3, 2026

    @lmeyerov
    ContributorAuthor

    Status of the three follow-ups (2026-10-03), each with its own evidence trail in the PR:

  4. lmeyerov commented on Oct 3, 2026

    @lmeyerov
    ContributorAuthor

    Landed in #2117 (merge ceb095f). The multi-seed hop is served by the resident indexes end to end — WHERE a.id IN [...] lowers onto the pattern as is_in, the bindings kernel accepts the membership seed, and the row pipeline's hop reaches the adjacency index; 100k nodes / 500k edges, 50 seeds: 4,004 ms → 10.5 ms (pandas), cuDF 19 ms, polars 5.4 ms. The shipped GraphBench/SNB board was re-measured at that tree and republished (graphistry/pyg-bench#283), so the docs drift budget is reset without a waiver. Remaining, separate: the native [n({"id": is_in(...)}), e_forward(), n()] chain still takes the isin-scan fast path and records nothing in gfql_explain; WHERE NOT (x IN [...]) raises in the AST evaluator.

  5. added 2 commits that reference this issue on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions