Faster graph queries with GFQL indexes#
Run the examples from the indexing release article on a local sample graph. This notebook covers all five graph index kinds, index creation inside a graph pipeline, and indexed lookups on business keys. No Graphistry account or graph database is required.
Install the latest PyGraphistry release with Polars support in your notebook environment:
python -m pip install --upgrade "graphistry[polars]"
With uv, use uv pip install --upgrade "graphistry[polars]".
See the indexing guide for the supported query forms.
Set up accounts and transfers#
Choose pandas or polars. The same examples run on both.
[2]:
engine = 'pandas'
[3]:
import pandas as pd
import graphistry
nodes = pd.DataFrame({
"id": range(10000),
"account_number": range(48112, 58112),
})
edges = pd.DataFrame({
"src": range(9999),
"dst": range(1, 10000),
"transaction_id": [f"txn-{i}" for i in range(9999)],
})
if engine == "polars":
import polars as pl
nodes = pl.from_pandas(nodes)
edges = pl.from_pandas(edges)
g = graphistry.edges(edges, "src", "dst").nodes(nodes, "id")
g = g.gfql_index_all()
[4]:
g.show_indexes()[["kind", "key_col", "valid"]]
[4]:
| kind | key_col | valid | |
|---|---|---|---|
| 0 | edge_in_adj | dst | True |
| 1 | edge_out_adj | src | True |
| 2 | node_id | id | True |
Create each index and use it#
Outgoing and incoming hops use the adjacency indexes alongside the node-id index built above.
edge_out_adj#
Outgoing hops from known nodes.
[5]:
g.gfql(
'CREATE GFQL INDEX FOR edge_out_adj; MATCH (a {id: 101})-->(b) RETURN b'
)._nodes
[5]:
| b.id | b.account_number | |
|---|---|---|
| 102 | 102 | 48214 |
edge_in_adj#
Incoming hops to known nodes.
[6]:
g.gfql(
'CREATE GFQL INDEX FOR edge_in_adj; MATCH (a {id: 102})<--(b) RETURN b'
)._nodes
[6]:
| b.id | b.account_number | |
|---|---|---|
| 101 | 101 | 48213 |
node_id#
Lookups on the graph’s node id column.
[7]:
g.gfql(
'CREATE GFQL INDEX FOR node_id; MATCH (a {id: 101}) RETURN a'
)._nodes
[7]:
| a.id | a.account_number | |
|---|---|---|
| 101 | 101 | 48213 |
node_prop#
Filters on a node property.
[8]:
g.gfql(
'CREATE GFQL INDEX FOR node_prop ON (account_number); MATCH (a {account_number: 48213})-->(b) RETURN b'
)._nodes
[8]:
| b.id | b.account_number | |
|---|---|---|
| 102 | 102 | 48214 |
edge_prop#
Filters on an edge property.
[9]:
g.gfql(
"CREATE GFQL INDEX FOR edge_prop ON (transaction_id); MATCH (a)-[e {transaction_id: 'txn-101'}]->(b) RETURN b"
)._nodes
[9]:
| b.id | b.account_number | |
|---|---|---|
| 0 | 102 | 48214 |
Start from a list of accounts#
[10]:
account_ids = list(range(101, 151))
query = f"MATCH (u)-[e]->(other) WHERE u.id IN {account_ids} RETURN other"
contacts = g.gfql(query)
contacts._nodes
[10]:
| other.id | other.account_number | |
|---|---|---|
| 0 | 102 | 48214 |
| 1 | 103 | 48215 |
| 2 | 104 | 48216 |
| 3 | 105 | 48217 |
| 4 | 106 | 48218 |
| 5 | 107 | 48219 |
| 6 | 108 | 48220 |
| 7 | 109 | 48221 |
| 8 | 110 | 48222 |
| 9 | 111 | 48223 |
| 10 | 112 | 48224 |
| 11 | 113 | 48225 |
| 12 | 114 | 48226 |
| 13 | 115 | 48227 |
| 14 | 116 | 48228 |
| 15 | 117 | 48229 |
| 16 | 118 | 48230 |
| 17 | 119 | 48231 |
| 18 | 120 | 48232 |
| 19 | 121 | 48233 |
| 20 | 122 | 48234 |
| 21 | 123 | 48235 |
| 22 | 124 | 48236 |
| 23 | 125 | 48237 |
| 24 | 126 | 48238 |
| 25 | 127 | 48239 |
| 26 | 128 | 48240 |
| 27 | 129 | 48241 |
| 28 | 130 | 48242 |
| 29 | 131 | 48243 |
| 30 | 132 | 48244 |
| 31 | 133 | 48245 |
| 32 | 134 | 48246 |
| 33 | 135 | 48247 |
| 34 | 136 | 48248 |
| 35 | 137 | 48249 |
| 36 | 138 | 48250 |
| 37 | 139 | 48251 |
| 38 | 140 | 48252 |
| 39 | 141 | 48253 |
| 40 | 142 | 48254 |
| 41 | 143 | 48255 |
| 42 | 144 | 48256 |
| 43 | 145 | 48257 |
| 44 | 146 | 48258 |
| 45 | 147 | 48259 |
| 46 | 148 | 48260 |
| 47 | 149 | 48261 |
| 48 | 150 | 48262 |
| 49 | 151 | 48263 |
Build and query in one call#
[11]:
g.gfql("CREATE GFQL INDEX IF NOT EXISTS FOR edge_out_adj; "
"CREATE GFQL INDEX IF NOT EXISTS FOR node_id; "
"MATCH (u)-[e]->(other) WHERE u.id IN [101, 102] RETURN other")
[11]:
{'bindings': {'edges': Empty DataFrame
Columns: [e, src, dst, transaction_id]
Index: [],
'nodes': other.id other.account_number
0 102 48214
1 103 48215,
'source': 'src',
'destination': 'dst',
'node': 'id',
'edge_label': None,
'edge_color': None,
'edge_size': None,
'edge_weight': None,
'edge_title': None,
'edge_icon': None,
'edge_opacity': None,
'edge_source_color': None,
'edge_destination_color': None,
'point_label': None,
'point_color': None,
'point_size': None,
'point_weight': None,
'point_title': None,
'point_icon': None,
'point_opacity': None,
'point_x': None,
'point_y': None},
'settings': {'height': 500, 'url_params': {'info': 'true'}}}
Index an intermediate graph#
[12]:
# Narrow the graph, index it, then reuse it for a hop and degree.
pipeline_ids = list(range(101, 201))
ring = g.gfql(
f"GRAPH reached = GRAPH {{ MATCH (u)-[e1]->(mid)-[e2]->(other) WHERE u.id IN {pipeline_ids} }} "
"GRAPH adjacency = GRAPH { USE reached CALL graphistry.create_index.write({kind: 'edge_out_adj'}) } "
"GRAPH indexed = GRAPH { USE adjacency CALL graphistry.create_index.write({kind: 'node_id'}) } "
"GRAPH contacts = GRAPH { USE indexed MATCH (u {id: 101})-[e]->(other) } "
"GRAPH { USE contacts CALL graphistry.degree.write() }"
)
ring._nodes
[12]:
| id | mid | account_number | u | other | degree_in | degree_out | degree | |
|---|---|---|---|---|---|---|---|---|
| 0 | 101 | False | 48213 | True | False | 0 | 1 | 1 |
| 1 | 102 | True | 48214 | False | True | 1 | 0 | 1 |
Look up business keys#
[13]:
account_contacts = g.gfql(
"CREATE GFQL INDEX IF NOT EXISTS FOR edge_out_adj; "
"CREATE GFQL INDEX IF NOT EXISTS FOR node_id; "
"CREATE GFQL INDEX IF NOT EXISTS FOR node_prop ON (account_number); "
"MATCH (u {account_number: 48213})-[e]->(other) RETURN other"
)
account_contacts._nodes
transactions = g.gfql(
"CREATE GFQL INDEX IF NOT EXISTS FOR edge_prop ON (transaction_id); "
"MATCH (u)-[e {transaction_id: 'txn-101'}]->(other) RETURN other"
)
transactions._nodes
[13]:
| other.id | other.account_number | |
|---|---|---|
| 0 | 102 | 48214 |
Check index use#
[14]:
report = g.gfql_explain("MATCH (u {id: 101})-[e]->(other) RETURN other")
report["used_index"] # True
[14]:
True
Other property keys#
The same property index kinds support categorical labels, exact floats and timestamps. These examples keep the graph’s integer ids and index the business columns separately.
[15]:
key_nodes = pd.DataFrame({
"id": range(10000),
"segment": pd.Categorical([f"segment-{i}" for i in range(10000)]),
"amount": [i + 0.5 for i in range(10000)],
"event_time": pd.date_range("2024-01-01", periods=10000, freq="min"),
})
if engine == "polars":
key_nodes = pl.from_pandas(key_nodes)
key_graph = graphistry.nodes(key_nodes, "id")
for column in ["segment", "amount", "event_time"]:
key_graph = key_graph.create_index("node_prop", column=column)
key_graph.gfql("MATCH (a {segment: 'segment-101'}) RETURN a")._nodes
[15]:
| a.id | a.segment | a.amount | a.event_time | |
|---|---|---|---|---|
| 101 | 101 | segment-101 | 101.5 | 2024-01-01 01:41:00 |
[16]:
key_graph.gfql("MATCH (a {amount: 101.5}) RETURN a")._nodes
[16]:
| a.id | a.segment | a.amount | a.event_time | |
|---|---|---|---|---|
| 101 | 101 | segment-101 | 101.5 | 2024-01-01 01:41:00 |
[17]:
key_graph.filter_nodes_by_dict(
{"event_time": pd.Timestamp("2024-01-01 01:41:00")}, engine=engine
)._nodes
[17]:
| id | segment | amount | event_time | |
|---|---|---|---|---|
| 101 | 101 | segment-101 | 101.5 | 2024-01-01 01:41:00 |
Integer id lists through the Python API#
[18]:
from graphistry import n, e_forward, is_in
native_query = [n({"id": is_in([101, 201])}), e_forward(), n()]
g.gfql(native_query)._nodes
[18]:
| id | account_number | |
|---|---|---|
| 101 | 101 | 48213 |
| 102 | 102 | 48214 |
| 201 | 201 | 48313 |
| 202 | 202 | 48314 |
Filters across two nodes#
Index both query branches and both hop directions. The account-id branch and the destination’s account-number branch can then supply candidates for the same query.
[19]:
or_graph = g.create_index("node_prop", column="account_number")
or_query = (
"MATCH (a)-[e]->(b) WHERE a.id IN [101, 201] OR b.account_number = 48214 "
"RETURN a.id AS src, b.id AS dst"
)
or_graph.gfql(or_query)._nodes
[19]:
| src | dst | |
|---|---|---|
| 0 | 101 | 102 |
| 1 | 201 | 202 |
[20]:
or_report = or_graph.gfql_explain(or_query)
assert or_report["used_index"]
or_report["steps"]
[20]:
[{'op': 'fast_path',
'operation': 'fast_path',
'seam': 'single_hop_grouped_aggregate',
'engine': 'auto',
'served': False,
'reason': 'declined; caller falls back',
'path': 'scan',
'decision_reason': 'declined; caller falls back',
'decision_code': 'index_path_unavailable'},
{'op': 'fast_path',
'operation': 'fast_path',
'seam': 'two_hop_count',
'engine': 'auto',
'served': False,
'reason': 'declined; caller falls back',
'path': 'scan',
'decision_reason': 'declined; caller falls back',
'decision_code': 'index_path_unavailable'},
{'op': 'fast_path',
'operation': 'fast_path',
'seam': 'seeded_typed_hop',
'engine': 'auto',
'served': False,
'reason': 'declined; caller falls back',
'path': 'scan',
'decision_reason': 'declined; caller falls back',
'decision_code': 'index_path_unavailable'},
{'op': 'fast_path',
'operation': 'fast_path',
'seam': 'seeded_node_lookup',
'engine': 'auto',
'served': False,
'reason': 'declined; caller falls back',
'path': 'scan',
'decision_reason': 'declined; caller falls back',
'decision_code': 'index_path_unavailable'},
{'op': 'property_lookup',
'role': 'nodes',
'column': 'account_number',
'index_kind': 'node_prop',
'engine': 'pandas',
'policy': 'use',
'est_result_rows': 1,
'path': 'index',
'decision_code': 'index_selected',
'decision_reason': 'property candidates gathered'},
{'op': 'indexed_traversal',
'operation': 'indexed_traversal',
'seam': 'cross_alias_or',
'engine': 'pandas',
'served': True,
'reason': 'served',
'hops': 1,
'hop_count': 1,
'public_seed_scan': False,
'hop_details': [{'hop': 1, 'estimated_rows': 2}],
'path': 'index',
'decision_reason': 'served',
'decision_code': 'index_selected'}]
Return only the rows you need#
For an unfiltered one-hop query on pandas or cuDF, LIMIT avoids building the remaining result rows. Endpoint columns still scan. Polars uses its existing path.
[21]:
g.gfql("MATCH (a)-[e]->(b) RETURN a.id AS src, b.id AS dst LIMIT 10")._nodes
[21]:
| src | dst | |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 2 |
| 2 | 2 | 3 |
| 3 | 3 | 4 |
| 4 | 4 | 5 |
| 5 | 5 | 6 |
| 6 | 6 | 7 |
| 7 | 7 | 8 |
| 8 | 8 | 9 |
| 9 | 9 | 10 |
Check that indexes preserve the answer#
[22]:
indexed = or_graph.gfql(or_query)._nodes
scanned = or_graph.gfql(or_query, index_policy="off")._nodes
if engine == "pandas":
pd.testing.assert_frame_equal(indexed, scanned)
else:
assert indexed.equals(scanned)
assert len(indexed) == 2
Read the indexing guide for index lifetime, memory use and planner controls. Continue with Cypher filtering and PageRank for a larger graph pipeline.