Setup
GraphRAG augments retrieval-augmented generation with a knowledge graph: entities
and their relationships give a retriever a second axis (multi-hop traversal) alongside
similarity search. This notebook builds a tiny graph — people, documents, and the edges
between them — then walks every retrieval primitive Arcadic ships: bulk graph ingest,
idempotent upsert, dense and sparse vector indexes, full-text search, hybrid fusion,
INT8-quantized vectors, and a Cypher traversal.
This notebook needs a running ArcadeDB. The quickest way is Docker:
docker run -d --name arcadic-graphrag \
-p 2480:2480 \
-e JAVA_OPTS="-Darcadedb.server.rootPassword=playwithdata" \
arcadedata/arcadedb:latestOnly the HTTP port (2480) is needed — every operation below (Bulk, FullText,
Vector, Param) is HTTP-only; none of it runs over Bolt.
Every connection value can be overridden with an environment variable set before you
launch Livebook (ARCADIC_HTTP_URL, ARCADIC_PASSWORD); otherwise the defaults match
the docker run above.
Re-running cells. Most cells re-run cleanly: the raw
CREATE VERTEX/EDGE TYPEandCREATE PROPERTYstatements carryIF NOT EXISTSguards, the dense-vector and full-text index helpers emitIF NOT EXISTSinternally, and the upsert, search, fuse, and traversal cells are idempotent or read-only. Two cells are not re-run-safe. The "Build a small knowledge graph" cell (Section 1) has guarded type-creates but then calls create-onlyArcadic.Bulk.ingest/3, so re-running it adds a second copy of every vertex and edge. The "Dense + sparse vector indexes" cell (Section 3) raisesalready existson re-run, because theLSM_SPARSE_VECTORindex has noIF NOT EXISTSform (create_sparse_index!takes no such flag). To redo either, run the Cleanup cell at the bottom, then run all cells top to bottom.
Install
Requires arcadic ≥ 0.5.
Bulk,FullText,Vector.fuse/3, andParamare not yet in a published release, so this notebook errors under a~> 0.4pin. To run it today, point the install at the repo instead —{:arcadic, github: "baselabs/arcadic"}(or a localpath:dep). Once 0.5 publishes, the~> 0.5pin below resolves to it and this note no longer applies.
Mix.install([
{:arcadic, "~> 0.7"},
{:kino, "~> 0.14"}
])Connect
We create a throwaway graphrag_quickstart database to play in (only if it does not
already exist, so this cell is safe to re-run).
http_url = System.get_env("ARCADIC_HTTP_URL", "http://localhost:2480")
password = System.get_env("ARCADIC_PASSWORD", "playwithdata")
database = "graphrag_quickstart"
admin = Arcadic.connect(http_url, database, auth: {"root", password})
{:ok, true} = Arcadic.Server.ready?(admin)
unless match?({:ok, true}, Arcadic.Server.database_exists?(admin, database)) do
Arcadic.Server.create_database!(admin, database)
end
conn = Arcadic.connect(http_url, database, auth: {"root", password})1. Build a small knowledge graph
Arcadic.Bulk.ingest/3 bulk-creates vertices and edges in one POST to ArcadeDB's batch
endpoint — structured record ingest, not statement text, so it is injection-inert. It
does not create schema, so the vertex/edge types must already exist.
Each vertex record carries an "@id" — a string that is only a temporary handle
for wiring edges within this one batch; it is never persisted as a property. Each
edge record's "@from"/"@to" reference those "@id" values. Because "@id" isn't
persisted, we also give each vertex a real "id" property — the stable business key
we'll MATCH/MERGE on in later cells, after the batch handles are long gone. The
200 response's id_mapping maps every temporary "@id" to the real RID ArcadeDB
assigned it.
Arcadic.command!(conn, "CREATE VERTEX TYPE Person IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE VERTEX TYPE Document IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE EDGE TYPE KNOWS IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE EDGE TYPE WROTE IF NOT EXISTS", %{}, language: "sql")
{:ok, counts} =
Arcadic.Bulk.ingest(conn, [
%{
"@type" => "vertex",
"@class" => "Person",
"@id" => "alice",
"id" => "alice",
"name" => "Alice",
"role" => "engineer"
},
%{
"@type" => "vertex",
"@class" => "Person",
"@id" => "bob",
"id" => "bob",
"name" => "Bob",
"role" => "researcher"
},
%{
"@type" => "vertex",
"@class" => "Document",
"@id" => "doc1",
"id" => "doc1",
"title" => "Graph databases 101",
"body" => "A graph database models data as nodes and edges for fast multi-hop traversal."
},
%{
"@type" => "vertex",
"@class" => "Document",
"@id" => "doc2",
"id" => "doc2",
"title" => "Vector search primer",
"body" => "Vector search finds nearest neighbors in embedding space for semantic retrieval."
},
%{
"@type" => "vertex",
"@class" => "Document",
"@id" => "doc3",
"id" => "doc3",
"title" => "Fraud detection with graphs",
"body" => "Fraud rings can be uncovered by traversing the graph of transactions."
},
%{"@type" => "edge", "@class" => "KNOWS", "@from" => "alice", "@to" => "bob"},
%{"@type" => "edge", "@class" => "WROTE", "@from" => "alice", "@to" => "doc1"},
%{"@type" => "edge", "@class" => "WROTE", "@from" => "alice", "@to" => "doc3"},
%{"@type" => "edge", "@class" => "WROTE", "@from" => "bob", "@to" => "doc2"}
])
{counts.vertices_created, counts.edges_created}counts.id_mapping
|> Enum.map(fn {temp_id, rid} -> %{"temp_id" => temp_id, "rid" => rid} end)
|> Kino.DataTable.new()2. Batched upsert (UNWIND)
Bulk.ingest is fast but create-only — a retry after a lost response duplicates
every record. For an idempotent batch write, use the UNWIND $rows idiom over
Arcadic.command/4 instead: one MERGE per unwound row, matched on the real id
property (not a batch-scoped "@id"). This updates Alice's role and adds a new person,
Carol, who was not part of the original bulk load.
rows = [
%{"id" => "alice", "props" => %{"role" => "staff engineer"}},
%{"id" => "carol", "props" => %{"name" => "Carol", "role" => "PM"}}
]
merge = "UNWIND $rows AS r MERGE (p:Person {id: r.id}) SET p += r.props RETURN count(p) AS c"
{:ok, [%{"c" => 2}]} = Arcadic.command(conn, merge, %{"rows" => rows})
# Replay the identical batch — MERGE matches the existing people instead of duplicating
# them, which is exactly what Bulk.ingest above would NOT have done.
{:ok, [%{"c" => 2}]} = Arcadic.command(conn, merge, %{"rows" => rows})
Arcadic.query!(conn, "MATCH (p:Person) RETURN count(p) AS total", %{})3. Dense + sparse vector indexes
Arcadic.Vector.create_dense_index!/5 builds an LSM_VECTOR index for nearest-neighbor
search over a float embedding. create_sparse_index!/5 builds an LSM_SPARSE_VECTOR
index over a (tokens, weights) pair for a learned-sparse / BM25-style search.
Ordering matters for the sparse index: unlike dense (and unlike FullText, next
section), a sparse index does not retro-index rows that already exist — only rows
written after the index is created become searchable. We create both indexes before
setting any vector data on the Document rows the bulk step above already created.
Arcadic.command!(conn, "CREATE PROPERTY Document.embedding IF NOT EXISTS ARRAY_OF_FLOATS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE PROPERTY Document.tokens IF NOT EXISTS ARRAY_OF_INTEGERS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE PROPERTY Document.weights IF NOT EXISTS ARRAY_OF_FLOATS", %{}, language: "sql")
:ok = Arcadic.Vector.create_dense_index!(conn, "Document", "embedding", 3, similarity: :cosine)
# create-before-load — see the caveat above.
:ok = Arcadic.Vector.create_sparse_index!(conn, "Document", "tokens", "weights")
doc_vectors = [
%{
"id" => "doc1",
"embedding" => [1.0, 0.0, 0.0],
"tokens" => [10, 11, 12],
"weights" => [0.9, 0.5, 0.2]
},
%{
"id" => "doc2",
"embedding" => [0.0, 0.0, 1.0],
"tokens" => [20, 21, 22],
"weights" => [0.7, 0.4, 0.3]
},
%{
"id" => "doc3",
"embedding" => [0.9, 0.1, 0.0],
"tokens" => [10, 13, 14],
"weights" => [0.6, 0.5, 0.4]
}
]
Arcadic.command!(
conn,
"UNWIND $rows AS r MATCH (d:Document {id: r.id}) " <>
"SET d.embedding = r.embedding, d.tokens = r.tokens, d.weights = r.weights",
%{"rows" => doc_vectors}
)4. Full-text index + search
Arcadic.FullText.create_index/4 builds a Lucene-backed FULL_TEXT index. Unlike the
sparse vector index above, FULL_TEXT does retro-index existing rows — the three
Documents the bulk step created are searchable immediately, no reload required.
search/5 with with_score: true projects the BM25 relevance score alongside each row.
Arcadic.command!(conn, "CREATE PROPERTY Document.body IF NOT EXISTS STRING", %{}, language: "sql")
:ok = Arcadic.FullText.create_index(conn, "Document", "body")
{:ok, rows} =
Arcadic.FullText.search(conn, "Document", "body", "graph", with_score: true, limit: 5)
Kino.DataTable.new(rows)5. Hybrid retrieval (fuse)
Arcadic.Vector.fuse/3 runs several heterogeneous neighbor subqueries — dense, sparse,
and/or full-text — and returns one fused, re-ranked list (reciprocal-rank fusion by
default). Here we combine all three modalities: a dense arm (nearest to doc1's embedding
direction), a sparse arm (nearest to doc1's learned-sparse (tokens, weights)
signature, using the values loaded in Section 3), and a full-text arm ("graph"). doc1
and doc3 match all three signals and rank highest; doc2 matches only the dense arm and
trails.
{:ok, rows} =
Arcadic.Vector.fuse(conn, [
{"Document", "embedding", [1.0, 0.0, 0.0], 3},
{:sparse, "Document", "tokens", "weights", [10, 11, 12], [0.9, 0.5, 0.2], 3},
{:fulltext, "Document", "body", "graph", 5}
])
Kino.DataTable.new(rows)6. INT8-quantized ingest
Arcadic.Param.int8/1 wraps a list of signed bytes (-128..127) as ArcadeDB's $int8
typed-param marker, decoded server-side into a BINARY property — a compact way to
store a quantized embedding signature. Requires ArcadeDB ≥ 26.5.1; HTTP-only (the
decode lives in ArcadeDB's HTTP command handler — inert over Bolt).
Arcadic.command!(conn, "CREATE PROPERTY Document.signature IF NOT EXISTS BINARY", %{}, language: "sql")
Arcadic.command!(
conn,
"UPDATE Document SET signature = :sig WHERE id = :id",
%{"sig" => Arcadic.Param.int8([0, 64, 127, -1, -128]), "id" => "doc1"},
language: "sql"
)
Arcadic.query!(conn, "SELECT signature FROM Document WHERE id = :id", %{"id" => "doc1"},
language: "sql"
)7. Graph traversal (multi-hop retrieval)
The graph half of graphRAG: instead of (or alongside) similarity search, hop across
relationships to pull in context. Starting from Alice, follow KNOWS to a contact, then
WROTE to that contact's documents — "documents written by people Alice knows."
Arcadic.query!(
conn,
"MATCH (a:Person {id: $start})-[:KNOWS]->(friend:Person)-[:WROTE]->(d:Document) " <>
"RETURN friend.name AS author, d.title AS title",
%{"start" => "alice"}
)Cleanup
Drop the throwaway database when you are done (or before a fresh top-to-bottom run).
Arcadic.Server.drop_database(admin, database)