Skip to content

The CSR index

The CSR is what makes lance-csr different from the project it was forked from. This page explains what it is, how to turn it on, which queries it speeds up, and how it keeps up with changing data.

In one sentence

CypherEngine(config, datasets, use_csr=True) builds a compact in-memory neighbour list for every relationship type, so each hop of a traversal becomes an array lookup instead of a hash join, with exactly the same results.

Why joins are slow for traversal

Without a CSR, a hop such as (a:Person)-[:KNOWS]->(b:Person) is two hash joins: the a rows against the whole KNOWS table, then the result against the whole Person table to fetch b. A three-hop query repeats that three times, building a hash table and materializing every intermediate path at each step.

flowchart TB
    subgraph join ["Join path: two hash joins per hop"]
        direction LR
        A1["a rows"] --> J1["hash join<br/>KNOWS table"] --> J2["hash join<br/>Person table"] --> B1["b rows"]
    end
    subgraph csr ["CSR path: one lookup per hop"]
        direction LR
        A2["a rows"] --> C["CsrExpand: neighbours of a,<br/>take b rows by position"] --> B2["b rows"]
    end
    join ~~~ csr

What a CSR is

Compressed sparse row is the standard compact layout for a sparse matrix, and so for a graph's adjacency. It is two arrays:

  • neighbours: every edge's target, grouped by source;
  • offsets: one entry per source node, pointing at where its neighbours start.

For the graph 1→2, 1→3, 2→4, 3→4:

Source position 0 (node 1) 1 (node 2) 2 (node 3) end
offsets 0 2 3 4
Index 0 1 2 3
neighbours 2 3 4 4

The neighbours of the node at position v are neighbours[offsets[v] .. offsets[v + 1]], so node 1's are neighbours[0..2] = [2, 3]. One lookup, no hashing of the edge table, and each node's neighbours sit next to each other in memory.

lance-csr builds two of these per relationship type, one outgoing and one incoming, so it can walk edges in either direction.

Turning it on

Python
import pyarrow as pa
from lance_csr import CypherEngine, GraphConfig

config = (
    GraphConfig.builder()
    .node_table("Person", "id")
    .rel_table("KNOWS", "Person", "Person", "src", "dst")
    .build()
)
datasets = {
    "Person": pa.table({"id": [1, 2, 3, 4], "name": ["Alice", "Bob", "Carol", "David"]}),
    "KNOWS": pa.table({"src": [1, 1, 2, 3], "dst": [2, 3, 4, 4]}),
}

engine = CypherEngine(config, datasets, use_csr=True)  # (1)!
  1. The CSR is built here, in the constructor, from the relationship tables alone. Nothing changes in how you write queries.

use_csr is False by default. With it on, the engine builds two indexes at construction:

CSR adjacency
Per relationship type with integer endpoints: outgoing and incoming neighbour lists. Built from the relationship table only. Each neighbour also records the row its edge came from (its Lance row address, or its row number in an in-memory table), so a hop can fetch the relationship's own columns by position.
Node index
Per node label with unique integer keys: a map from key to row position. It lets the CSR fetch target rows by position (Lance take_rows, or Arrow take for in-memory tables) instead of joining.

Memory cost

Expect about 95 bytes per edge of extra peak memory while the CSR is built, for both directions together (measured at 10M edges with python/benchmarks/csr_expand.py). Afterwards, each direction keeps 8 bytes per edge for the neighbour key and about 2 for the edge's row, which is stored packed, plus about 37 bytes per source node for its offset and its entry in the map from key to position. On the GDC SF1 graph that's 732 MiB for 17.3 million edges, both directions. The index lives in memory even when the tables are read lazily from Lance.

During query execution, each CSR expansion partition keeps one input batch and collects at most one output chunk of neighbour positions before fetching properties. It yields that chunk before visiting the remaining neighbours, even for a single high-degree node. Deleted edge positions are read from the shared snapshot rather than copied for each query; checkout_latest() resolves those positions by scanning the affected sources' neighbour lists.

Cancellation checkpoints count source rows and frontiers with no surviving edges, deleted edges and suppressed reverse self-loops as work, using borrowed adjacency slices while a chunk is being collected. The chunk row limit comes from DataFusion's batch size; wide properties and multiple active partitions still increase working memory. This does not bound the resident graph, the input data, or buffering in joins, sorts and aggregations elsewhere in the plan.

Experimental

The CSR is marked experimental. Results are identical with it on or off (the test suite runs each supported shape both ways and compares), but its API and statistics may change between releases.

Which hops use it

The CSR answers a hop when all of these hold:

  • the relationship has integer endpoint columns (int8, int16, int32, int64, uint8, uint16 or uint32);
  • the hop has exactly one relationship type (alternation still qualifies, because each alternative is planned as its own branch);
  • the relationship has no inline property map: -[:KNOWS]-> and -[k:KNOWS]-> qualify, -[:KNOWS {since: 2020}]-> does not (write WHERE k.since = 2020 instead).

When those hold, it covers:

Pattern CSR?
(a)-[:KNOWS]->(b), (a)<-[:KNOWS]-(b) outgoing or incoming adjacency
(a)-[:KNOWS]-(b) both adjacencies
(a)-[:KNOWS*1..3]->(b) one iterative expansion for the whole range
(t:Tag)-[:hasType|isSubclassOf*1..3]->(c:TagClass) typed iterative expansion when every type and label is indexed
(a)-[:LIKES_POST|LIKES_COMMENT]->(m) one expansion per branch
OPTIONAL MATCH (a)-[:KNOWS]->(b)
shortestPath((a)-[:KNOWS*]-(b)) the search walks the CSR adjacency
WHERE NOT (b)-[:KNOWS]->(a), (p)-[:HAS_TAG]->()<-[:INTEREST]-(person) walked per row from a bound node, through anonymous nodes and relationships of distinct types (PathExists)
(a)-[k:KNOWS]->(b) k's other columns are fetched by edge row (single hops only)
(a)-[k:KNOWS {since: 2020}]->(b) hash join
endpoints of type string, float or uint64 hash join

Everything else in the query (filters, aggregations, sorts and any hop the CSR can't take) is planned by DataFusion as usual. You can mix: a three-hop query might run two hops on the CSR and one as a join.

Named path values and relationships(path) use a query-local inventory of actual edge occurrences because CSR neighbors do not carry edge identity or properties. This also preserves parallel edges when a path passes through collect() and UNWIND. The inventory materializes the participating node and relationship tables; bounded traversal batches do not bound that inventory. See path values. Shortest-path queries within one node label that only read length or node keys keep the CSR route below. Relationships crossing labels use typed identities, even for these queries, to distinguish equal keys from different labels.

For shortestPath and allShortestPaths, a source with one target in an input batch searches from both ends using the outgoing and incoming CSR. It expands the smaller frontier and finishes the whole layer before joining paths, so it keeps all shortest alternatives. Several targets for the same source share one forward search. Undirected searches can use both ends without a persistent CSR too; directed searches without a CSR keep their forward search over the adjacency built for that query.

Variable-length traversal

An eligible variable-length path keeps one cursor per hop and yields bounded output batches. Its plan contains one CsrExpandExec with iterative hops=..., rather than a branch for every possible length or sequence of relationship types. It preserves duplicate matches and skips missing nodes at intermediate hops. A node's label and key travel together, so the same integer key in two labels identifies two nodes.

The iterative path needs CSR indexes for every traversed type and direction, node indexes for every intermediate label, and resolved labels for the endpoints. Unsupported index shapes retain the join fallback. The 20-hop maximum still applies. *0.. includes a zero-hop identity match when both labels and keys agree. Undirected paths keep the existing rule that rejects immediate backtracking; neither execution path enforces full relationship uniqueness on longer cycles.

Traversal state grows with the hop limit, not the number of pending paths. Resident graph indexes, input batches, fetched target properties and downstream operators still contribute to query memory. Branching graphs can produce exponentially many matches even though traversal resumes between output batches.

Relationship variables

A hop that names its relationship, as in MATCH (a)-[k:KNOWS]->(b) WHERE k.since > 2020 RETURN b.name, stays on the CSR. Each neighbour carries the row its edge came from, so the expansion fetches the columns of k that the query reads by position (Lance take by row address, or Arrow take), and only those. explain() shows them as edge=k[k__since] on the CsrExpand line. Parallel edges between the same two nodes are separate rows, each with its own properties, as on the join path.

A WHERE condition that reads only k's own columns, such as k.since > 2020, moves into the expansion (edge_filter= in explain()), so failing edges are dropped before anything else is fetched. A small hop fetches just the filter's columns for each batch of expanded edges. On a Lance dataset, once a hop has expanded more than 1/256 of the relationship's edges, it reads the filter's columns once, in order, and skips failing edges from then on. In-memory PyArrow tables always filter each batch, since fetching their columns is an in-memory gather. Conditions that mix k with other variables stay after the expansion.

Large hops on lazily read tables

A hop that expands a large share of a relationship and returns several properties of its target node fetches those properties one batch of scattered rows at a time. On Lance datasets read lazily, that can be slower than the join path, which reads the node table once. This holds whether or not the hop names its relationship. IC5 in the benchmarks is such a query.

The fused target lookup

When the target label has a node index, the CSR expansion also replaces the join that fetches the target's properties: each neighbour id is mapped to a row position and the row is read directly. In explain() output that shows as take=:

Python
plan = engine.explain("MATCH (a:Person {id: 1})-[:KNOWS]->(b:Person) RETURN b.name")
print([line.strip(" |") for line in plan.splitlines() if "CsrExpandExec" in line])
# ['CsrExpandExec: key=a__id, edges=4, take=b[b__name]']

The fused lookup needs the target's key to be a unique integer. Duplicate keys, or a target that is already bound earlier in the query, keep the CSR hop but join for the target's properties.

Only requested target columns are read. A pushed-down target filter reads its own columns first; remaining properties are fetched only for neighbours that pass. When a column is used by both the filter and the projection, its filtered values are reused within that output batch. Target keys come directly from the neighbour IDs, including when a filter reads them. This reuse ends with the batch and keeps no property cache across queries or dataset versions.

Grouping by a node

WITH b, count(*) AS n groups by the node b. When b's label has a node index and its table is in memory, the grouping uses b's id alone and looks up b's other properties afterwards, once per node, by the same row positions. Rows before a grouping can outnumber nodes many times over (an expansion), and carrying a node's properties through the aggregate for each of them costs more than one lookup per node. With unique ids, the groups are the same. explain() shows the lookup as NodeLookupExec, with only the properties the query uses:

Python
plan = engine.explain(
    "MATCH (a:Person)-[:KNOWS]->(b:Person) WITH b, count(*) AS n RETURN b.name, n"
)
print([line.strip(" |") for line in plan.splitlines() if "NodeLookupExec" in line])
# ['NodeLookupExec: key=b__id, take=b[b__name]']

This applies only when the node table is held in memory. For a Lance dataset read lazily, grouping by a node still groups by all of its properties, as before. By the time the query groups by b, its rows already hold b's properties, read from the dataset by an earlier step, so grouping by them reads nothing more. Looking them up after the grouping would read them from disk a second time, which costs more than keeping them.

Patterns start at the most selective node

The planner rewrites each MATCH so traversal starts at its most selective node, walking edges backwards where needed. That matters more with the CSR than without, because DataFusion can push a filter below a join but not below a CSR expansion:

Python
plan = engine.explain("MATCH (a:Person)-[:KNOWS]->(b:Person {name: 'David'}) RETURN a.name")
print([line.strip(" |") for line in plan.splitlines() if "CsrExpandExec" in line])
# ['CsrExpandExec: key=b__id, edges=4, take=a[a__name]']

The expansion starts from b (key=b__id), the filtered node, and walks KNOWS incoming. Results are the same either way; only the order of work changes.

Inspecting the CSR

csr_stats()

One entry per relationship type that has a CSR:

Python
print(engine.csr_stats()["KNOWS"])
# {'edges': 4, 'delta_edges': 0, 'tombstones': 0, 'unindexed_edges': 0,
#  'update': 'build', 'version': None, 'base_version': None,
#  'build_ms': 0.41}  (build_ms varies)
Field Meaning
edges Live indexed edges. Edges with a null endpoint are skipped.
delta_edges Appended edges held outside the base CSR since its last full build.
tombstones Removed edges masked out since the last full build.
unindexed_edges Live edges outside the base CSR: appended since its last full build and not since removed. Lance index statistics call this num_unindexed_rows.
update How the last build, checkout_latest() or optimize_csr() produced it: build (first), rebuild (full), loaded (from a stored file, with any changes since applied as a delta), delta (changes applied) or reused (no edge change).
version The relationship dataset's Lance version, or None for PyArrow input.
base_version The version of the last full build, or None for PyArrow input. It trails version while deltas apply.
build_ms Wall time of that build or delta, both directions.

A relationship type missing from csr_stats() has no CSR (its endpoints aren't integers) and always joins.

node_index_stats()

Per node label: rows, version and build_ms. A label is missing when its keys are not unique integers.

Python
print(sorted(engine.node_index_stats()))  # ['Person']

Keeping the CSR current

A Lance dataset can change at any time. You, another process or LanceDB can append rows, delete them or compact the files, and every write commits a new version of the dataset. The engine doesn't follow those changes as they happen: when you create it, it opens each Lance dataset at its current version, builds the CSR from that version, and keeps answering queries from that snapshot until you tell it to look again. That keeps queries consistent with each other, but it also means the CSR falls behind as soon as anyone writes to the data.

Two calls bring it back up to date, and they do different jobs:

  1. checkout_latest() moves the engine to the newest version of each Lance dataset. Instead of rebuilding the CSR, it records the edges added or removed since the last build as a delta, a small set of changes kept next to the CSR. Call it whenever your queries should see new writes.
  2. optimize_csr() rebuilds a CSR from scratch, so it no longer needs its delta. Call it occasionally, once enough changes have built up that you'd rather lookups stopped working through them.

The two calls aren't a matched pair, though. checkout_latest() isn't really a CSR operation: it's named after, and works like, Lance's own LanceDataset.checkout_latest(), and it moves the whole engine to the newest version of every Lance dataset it reads. Node tables, node indexes and the statistics used to plan joins all move with it, and keeping the CSR in step is just one part of that, done as cheaply as possible with a delta. optimize_csr() is the call that's about the CSR itself: it rebuilds a CSR in a running engine when you decide, rather than waiting for the delta to get too large. It plays the part Lance's optimize_indices() plays for Lance's own indexes.

If you gave the engine in-memory PyArrow tables instead of Lance datasets, neither call applies: those tables can't change underneath the engine, so their CSR is never out of date. To query new in-memory data, create a new engine.

checkout_latest(): pick up new writes

Because each Lance dataset can evolve independently as you write to it, the engine has to be told that a newer version is available. checkout_latest() does that: it checks every Lance dataset for a newer version and moves the engine to it. Call it after the writes your queries should see, whether they came from pylance, LanceDB or another process. If one writer updates several tables, wait until all of them have committed before you call it, since each Lance dataset is pinned on its own (see snapshots are per Lance dataset). When nothing has changed, checkout_latest() keeps what the engine already has and returns quickly, so calling it on a timer or before each batch of queries is fine.

Rebuilding the whole CSR on every call would mean scanning every edge again, even when only a handful changed. So checkout_latest() reads only the new parts of each relationship dataset and applies the changes on top of the CSR from the last full build, which the rest of this page calls the base. Appended edges go into the delta, and deleted edges are marked so that lookups skip them; csr_stats() counts those marks as tombstones. Answers are exactly what a full rebuild would give. To see what a call did, check update in csr_stats(): reused means nothing changed, delta means the changes were applied, and rebuild means the CSR was built again from scratch, which happens in the cases below.

Python
import lance

lance.write_dataset(datasets["Person"], "person.lance")
lance.write_dataset(datasets["KNOWS"], "knows.lance")
engine = CypherEngine(
    config,
    {"Person": lance.dataset("person.lance"), "KNOWS": lance.dataset("knows.lance")},
    use_csr=True,
)

# David now knows Alice. The engine doesn't see the write until checkout_latest().
lance.write_dataset(pa.table({"src": [4], "dst": [1]}), "knows.lance", mode="append")
engine.checkout_latest()

stats = engine.csr_stats()["KNOWS"]
print(stats["edges"], stats["delta_edges"], stats["update"])  # 5 1 delta

This continues the on-disk example from Using from Rust, where Bob gains an edge to Alice.

Rust
// Bob now knows Alice too. The engine doesn't see the write until checkout_latest().
write_lance(knows(vec![2], vec![1])?, &knows_uri, WriteMode::Append).await?;
engine.checkout_latest().await?;
let result = engine.execute(query, params()).await?;
print_batches(&[result])?; // Alice, Carol, David

let stats = &engine.csr_stats()["KNOWS"];
println!(
    "{} edges, update={}, version={:?}",
    stats.edges, stats.update, stats.version
);
// 5 edges, update=delta, version=Some(2)
How checkout_latest() decides
flowchart LR
    R["checkout_latest()"] -- "no edge changes" --> U["reused"]
    R -- "changes under 25% of the base" --> DL["delta"]
    R -- "25% or more, or new endpoint columns" --> RB["rebuild"]
  • checkout_latest() rebuilds a CSR instead of adding to its delta once the changes since the last full build would reach 25% of the base, or when the relationship dataset was overwritten or its endpoint columns changed. The threshold is fixed: every lookup reads the delta as well as the base, so a large delta slows lookups down, and a rebuild clears it. python/benchmarks/csr_delta_accumulated.py measures that trade-off.
  • Deleted and rewritten rows are recognised by the edge row that each CSR entry records, so compacting a dataset or running cleanup_old_versions() doesn't force a rebuild: nothing needs to be read from the old version.
  • Writes to a node table never touch the CSR, because the CSR stores the target nodes' keys rather than their row positions. Only that label's node index is rebuilt.
  • The new CSR is built in full before the engine switches to it. If anything fails along the way, nothing changes, and queries that were already running finish on the snapshot they started with.

optimize_csr(): rebuild to clear the delta

The delta keeps checkout_latest() cheap, but every lookup has to check it as well as the base. It only rebuilds once the delta reaches 25% of the base, so a smaller delta can stay around indefinitely and slow lookups a little the whole time. optimize_csr() lets you clear it at a moment you choose. It rebuilds every CSR that has a delta, from a scan of all its edges, at the version the engine is already pinned to, and leaves CSRs without a delta alone. That costs about as much as building the CSR the first time, so it's best kept for after a bulk load, before a run of latency-sensitive queries, or a quiet period, rather than called after every checkout_latest(). Query results are the same before and after.

Python
engine.checkout_latest()  # applies the new edges as a delta
engine.optimize_csr()  # rebuilds the CSR, now including those edges
stats = engine.csr_stats()["KNOWS"]
print(stats["update"], stats["unindexed_edges"])  # rebuild 0

Afterwards, csr_stats() shows delta_edges, tombstones and unindexed_edges at 0, and base_version matches version. optimize_csr() doesn't look for newer data, so call checkout_latest() first if the rebuild should include the latest writes.

How this maps onto Lance's indexes

The CSR is maintained the same way Lance maintains its own indexes. A Lance index covers the fragments it was built from, and data written after that stays unindexed: searches combine the index's results with a scan of the unindexed rows, so answers stay correct while latency slowly grows, until you call optimize_indices() (see Lance's index maintenance guide and the index format). The CSR maps onto that one to one:

Lance index CSR
The fragments an index was built from The base CSR, at base_version
Unindexed rows, scanned alongside the index The delta (appended edges and tombstones), read by every lookup
num_unindexed_rows in index statistics unindexed_edges in csr_stats()
optimize_indices() optimize_csr()

Which one to call

checkout_latest() or optimize_csr()?

In short, checkout_latest() keeps the engine up to date at little cost, and optimize_csr() now and then clears the delta that checkout_latest() leaves behind.

checkout_latest() optimize_csr()
Data Moves to the latest version of each Lance dataset Stays at the version the engine is pinned to
Usual effect on a CSR Applies the new writes as a delta, reading only new fragments Rebuilds it from a scan of all its edges
Rebuilds a CSR when Its delta would pass 25% of the base It carries any delta at all
Cost In proportion to the new writes Like the first build
When to call it Whenever queries should see new writes Now and then, after checkout_latest(), once a delta has built up

Whenever either call rebuilds a CSR, it runs the same full build and ends up with the same CSR. Both build the replacement completely before the engine switches to it, and they never run at the same time.

Storing the CSR

Every engine builds its CSR from a full scan of the relationship tables, even when the data hasn't changed since the last run. With persist_csr=True, an engine over Lance datasets stores each CSR it builds and loads it on the next start instead:

Python
def open_engine():
    lance_datasets = {"Person": lance.dataset("person.lance"), "KNOWS": lance.dataset("knows.lance")}
    return CypherEngine(config, lance_datasets, use_csr=True, persist_csr=True)

print(open_engine().csr_stats()["KNOWS"]["update"])  # build: built, then stored
print(open_engine().csr_stats()["KNOWS"]["update"])  # loaded: read from the file

Each relationship dataset keeps its CSR in its own directory, one file per dataset version:

Text Only
knows.lance/
├── _versions/  data/  _deletions/  _indices/  _transactions/
└── _lance_csr/
    └── 42.arrow

The file belongs to the table: dropping or copying the dataset drops or copies it, and Lance's cleanup_old_versions() leaves the folder alone, since it only deletes the data, index, deletion and transaction files it recognises.

  • Changes since the stored version apply as a delta, read from the new fragments only, exactly as checkout_latest() applies them. update says loaded and base_version names the stored version. That holds after compaction and cleanup_old_versions() too.
  • Past the 25% threshold, the engine rebuilds the CSR and stores the new version. Every full build is stored, including those from checkout_latest() and optimize_csr(), and older files are deleted once a newer one is written.
  • A file that can't be used (corrupt, written for other endpoint columns or by another format) is ignored and rebuilt, so a bad file costs a build, never a wrong answer.
  • A dataset that can't be written, because of read-only storage or credentials, is built as without the option, and nothing is stored. Rust applications see the failed write as a log warning.
  • PyArrow inputs are never stored. That includes Lance data loaded into memory with to_table() first.

To go back to building in memory only, create engines without persist_csr and delete the stored files once with delete_stored_csr(). It removes the _lance_csr folder of every relationship dataset the engine reads and returns how many files it deleted; the engine keeps the CSR it already holds, so queries carry on as before:

Python
print(open_engine().delete_stored_csr())  # 1

Cost

The file holds the CSR's arrays as they sit in memory, compressed with zstd: on SF1 about 8 bytes per edge, 134 MiB in all. A load decompresses them and rebuilds only the map from key to position; node indexes are still built at start-up. On the GDC SF1 graph read lazily (17.3 million edges over 23 relationship types), measured on an Apple M5:

Engine start Peak memory
Without persist_csr 1.7 s 1.37 to 1.42 GiB
First start, building and storing 134 MiB 2.6 s 1.25 to 1.46 GiB
Later starts, loading 0.46 s 1.06 to 1.12 GiB

Compressing adds about 0.9 s to the first start; decompression takes most of a load. Query times don't change. python/benchmarks/csr_persist.py measures the same cases on a synthetic graph, including a load with a delta.

Should I turn it on?

Situation Recommendation
Queries traverse relationships, especially 2+ hops or variable-length Turn it on
Integer node keys and edge endpoints Turn it on; you get the fused target lookup too
String or UUID keys Little benefit; add an integer id column if traversal matters
Only node lookups and aggregations, no hops Leave it off; it costs build time and memory for nothing
Very large edge tables and tight memory Budget ~95 bytes per edge of peak memory before turning it on

The benchmarks were all run with use_csr=True.