Getting started¶
This page takes you from an empty environment to a working graph query. You'll install lance-csr, describe a small graph, and query it with Cypher.
Install¶
lance-csr is published on PyPI as lance-csr. Use uv:
Prebuilt wheels cover Linux x86-64 (glibc 2.28+: RHEL 8, Ubuntu 20.04 and later) and Apple Silicon
(macOS 11+), for CPython 3.12, 3.13 and 3.14. Installing a wheel needs no Rust toolchain. Other platforms
build from the source distribution, which needs Rust and protoc (see Contributing).
The package depends on pyarrow>=14 and pylance 13.x, which uv installs for you.
Using Rust?
The same engine is on crates.io: run cargo add lance-csr and see Using from Rust.
Docs track the main branch
These pages describe the code on main. The node_table and rel_table builder methods arrived in
0.2.0; on 0.1.0 use with_node_label and with_relationship, which were removed in 0.5.0 (see
Graph schema).
Check the install:
Your first graph¶
A graph in lance-csr is a set of ordinary tables plus a GraphConfig that says which tables are nodes,
which are relationships, and which columns link them. Here are people, the cities they live in, and who
knows whom:
import pyarrow as pa
datasets = {
"Person": pa.table({
"id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carol", "David"],
"age": [28, 34, 29, 42],
}),
"City": pa.table({"id": [10, 20], "name": ["London", "Sydney"]}),
"KNOWS": pa.table({"src": [1, 1, 2, 3], "dst": [2, 3, 4, 4]}),
"LIVES_IN": pa.table({"src": [1, 2, 3, 4], "dst": [10, 10, 20, 20]}),
}
let int = |values: Vec<i64>| Arc::new(Int64Array::from(values)) as ArrayRef;
let text = |values: Vec<&str>| Arc::new(StringArray::from(values)) as ArrayRef;
let datasets = HashMap::from([
(
"Person".to_string(),
RecordBatch::try_from_iter([
("id", int(vec![1, 2, 3, 4])),
("name", text(vec!["Alice", "Bob", "Carol", "David"])),
("age", int(vec![28, 34, 29, 42])),
])?,
),
(
"City".to_string(),
RecordBatch::try_from_iter([
("id", int(vec![10, 20])),
("name", text(vec!["London", "Sydney"])),
])?,
),
(
"KNOWS".to_string(),
RecordBatch::try_from_iter([
("src", int(vec![1, 1, 2, 3])),
("dst", int(vec![2, 3, 4, 4])),
])?,
),
(
"LIVES_IN".to_string(),
RecordBatch::try_from_iter([
("src", int(vec![1, 2, 3, 4])),
("dst", int(vec![10, 10, 20, 20])),
])?,
),
]);
Each key of datasets is a node label or relationship type. Now describe how they connect:
from lance_csr import GraphConfig
config = (
GraphConfig.builder()
.node_table("Person", "id") # (1)!
.node_table("City", "id")
.rel_table("KNOWS", "Person", "Person", "src", "dst") # (2)!
.rel_table("LIVES_IN", "Person", "City", "src", "dst")
.build()
)
- A node table: the label
Person, read from the dataset namedPerson, identified by itsidcolumn. - A relationship table:
KNOWSedges go fromPersontoPerson; thesrccolumn holds the source node'sidanddstthe target's.
Run a query¶
CypherEngine registers the datasets once and runs as many queries as you like against them:
from lance_csr import CypherEngine
engine = CypherEngine(config, datasets)
result = engine.execute("""
MATCH (p:Person)-[:LIVES_IN]->(c:City {name: 'London'})
RETURN p.name AS name, p.age AS age
ORDER BY age
""")
print(result.to_pylist())
# [{'name': 'Alice', 'age': 28}, {'name': 'Bob', 'age': 34}]
// The engine reads each dataset as an `EngineSource`; `false` leaves the CSR off.
let sources: HashMap<_, _> = datasets
.iter()
.map(|(name, batch)| (name.clone(), EngineSource::Arrow(batch.clone())))
.collect();
let engine = CypherEngine::new(config.clone(), sources.clone(), false).await?;
let result = engine
.execute(
"MATCH (p:Person)-[:LIVES_IN]->(c:City {name: 'London'})
RETURN p.name AS name, p.age AS age
ORDER BY age",
HashMap::new(),
)
.await?;
print_batches(&[result])?;
// +-------+-----+
// | name | age |
// +-------+-----+
// | Alice | 28 |
// | Bob | 34 |
// +-------+-----+
Results come back as a pyarrow.Table,
so to_pylist(), to_pydict(), to_pandas() and Polars' pl.from_arrow() all work. In Rust, execute
returns an Arrow RecordBatch.
Pass values with $parameters instead of formatting them into the query string:
Go multi-hop with the CSR¶
Pass use_csr=True and the engine builds a CSR adjacency index for each relationship type. Multi-hop
patterns then become array lookups instead of joins:
The third argument of CypherEngine::new is use_csr.
let engine = CypherEngine::new(config.clone(), sources, true).await?;
let friends_of_friends = engine
.execute(
"MATCH (a:Person {name: 'Alice'})-[:KNOWS]->()-[:KNOWS]->(c:Person)
RETURN DISTINCT c.name AS friend_of_friend",
HashMap::new(),
)
.await?;
print_batches(&[friends_of_friends])?;
// +------------------+
// | friend_of_friend |
// +------------------+
// | David |
// +------------------+
When should I turn the CSR on?
Turn it on when your queries traverse relationships, and especially for two or more hops. Node keys and edge endpoints must be integer columns to benefit; anything else quietly uses joins. Read The CSR index for the full picture.
One-off queries¶
For a single query you don't plan to repeat, CypherQuery skips the engine:
CypherQuery rebuilds its context on every execute() call, so prefer CypherEngine whenever you run more
than one query against the same data.
Next steps¶
- Graph schema: every option of
GraphConfig. - Querying with Cypher: parameters, streaming,
explain()and the supported Cypher. - Lance datasets: query data on disk without loading it, and pick up new writes with
refresh(). - Using from Rust: the same engine as a Rust library.
- The
examples/directory has runnable scripts.