Runic
A type-safe OGM for Cypher graph databases.
Define your models once, run them on any backend.
Why Runic • The OGM • Migrations • Graph-RAG • Installation • Docs
Runic maps Python classes to graph nodes and edges. You declare typed Node and Edge models
and get change tracking, lazy and eager relationships, a composable query API, and schema
migrations — on top of a pluggable driver layer that runs the same model code on FalkorDB,
Neo4j, Memgraph, ArcadeDB, and Apache AGE. A built-in Graph-RAG SDK layers
document ingestion and cited, knowledge-graph-grounded question answering on top of the OGM.
Why Runic
- Backend-agnostic. One model definition runs on five backends. Switching from FalkorDB to
Neo4j means changing the arguments to
create_driver(); your models, queries, and application code don't change. - Typed models, no metaclass magic.
NodeandEdgeare plain classes with typedFielddescriptors — IDE autocomplete works, andAuthor.name == "Alice"builds a query predicate. - Change tracking. Mutate an object and call
commit(); the unit-of-work session computes the diff and writes only what changed. No manual dirty flags, no hand-writtenSETclauses. - First-class relationships. Declare
Relationfields forINCOMING/OUTGOINGedges, with edge-property models, and choose lazy loading or single-round-trip eager fetch. - Native graph types.
Vector(vecf32),GeoLocation(point), interned strings, and automatic converters fordatetimeandEnum— stored without writing serialization code. - Migrations included. A migration tool with versioned revisions, a CLI, and rollback snapshots. Revision state lives inside the graph, so there's no external state table.
- Sync and async.
Session/RepositoryandAsyncSession/AsyncRepositoryshare one API.
The OGM
The two examples below build on one domain — Author, Article, and an AUTHORED edge —
and cover the features you'll reach for most.
1. Model a domain, persist it, and wire up relationships
Declare typed Node and Edge classes. Constraints and indexes go inline on Field; native
graph types (Vector, GeoLocation, datetime, Enum) get their converters assigned
automatically; and Relation declares a traversal with an optional edge-property model.
SchemaManager reconciles those declarations with the live graph, a Session handles writes
with automatic change tracking, and session.relate() creates the edges between nodes.
from datetime import UTC, datetime
from enum import StrEnum
from runic.ogm import (
Edge,
Field,
GeoLocation,
Node,
Relation,
SchemaManager,
Session,
Vector,
create_driver,
)
class Status(StrEnum):
DRAFT = "draft"
PUBLISHED = "published"
class Article(Node, labels=["Article"]):
id: str = Field(primary_key=True)
title: str = Field(
index_type="FULLTEXT"
) # fulltext search: FalkorDB/Neo4j/Memgraph
category: str = Field(
interned=True
) # intern() dedup — FalkorDB only, no-op elsewhere
status: Status = Status.DRAFT # EnumConverter auto-assigned
published_at: datetime | None = None # DatetimeConverter auto-assigned
embedding: Vector | None = None # KNN via vecf32() on FalkorDB; Neo4j/Memgraph
# need a pre-created VECTOR INDEX
origin: GeoLocation | None = None # point(); updates unsupported on ArcadeDB
class AuthoredEdge(Edge, type="AUTHORED"):
created_at: datetime
class Author(Node, labels=["Author"]):
id: str = Field(primary_key=True)
email: str = Field(unique=True)
name: str
articles: list[Article] = Relation(
relationship="AUTHORED",
direction="OUTGOING",
target="Article",
edge_model=AuthoredEdge,
)
# Pick a backend here — nothing else in this file changes.
driver = create_driver("falkordb", host="localhost", port=6379, graph="blog")
# Reconcile declared indexes/constraints with the live graph.
schema = SchemaManager(driver)
schema.sync_schema([Author, Article], drop_extra=False) # create missing; keep extras
with Session(driver) as session:
alice = Author(id="alice", email="alice@example.com", name="Alice")
intro = Article(
id="a1", title="Graphs 101", category="intro", status=Status.PUBLISHED
)
session.add_all([alice, intro])
session.commit()
# Create the AUTHORED edge, writing properties onto the relationship itself.
# relate() is MERGE-based: idempotent, and re-calling updates the edge props.
session.relate(
alice, Author.articles, intro, edge=AuthoredEdge(created_at=datetime.now(UTC))
)
session.commit()
with Session(driver) as session:
alice = session.get(Author, "alice")
alice.name = "Alice Smith" # tracked automatically — no explicit dirty flag
session.commit() # only the diff is written
2. Query and traverse the graph
Read data back with composable, type-safe statements, multi-hop traversals, paginated
repositories, or your own Cypher. select() builds a statement independently of any session,
so you can assemble it from conditional filters and reuse it across sessions. .traverse()
walks a relationship — one hop, or to any depth with hops= — for real-world graph queries.
from runic.ogm import Repository, Session, alias, select
# Compose a query dynamically, then run it three ways.
stmt = select(Article).where(Article.status == Status.PUBLISHED)
if category:
stmt = stmt.where(Article.category == category)
with Session(driver) as session:
articles: list[Article] = session.scalars(stmt) # list[Article]
latest: Article | None = session.scalar(stmt) # Article | None
n: int = session.count(stmt) # int
# Single-hop traversal with an edge-property filter — published articles
# Alice authored after a cutoff date.
e, art = alias(AuthoredEdge, "e"), alias(Article, "art")
recent = (
session.query(Author, "a")
.where(Author.id == "alice")
.traverse(Author.articles, to=art, edge=e)
.where(e.created_at >= cutoff)
.where(art.status == Status.PUBLISHED)
.return_target(art)
.all()
)
# Variable-length traversal — every article reachable within 3 AUTHORED hops
# (e.g. co-authorship chains). hops=(1, None) means unbounded.
network = (
session.query(Author, "a")
.where(Author.id == "alice")
.traverse(Author.articles, to="reached", hops=(1, 3))
.all()
)
# Paginate through a repository.
page = Repository(session, Article).find_all(skip=0, limit=20)
# Or load relationships off an entity: lazy by default, eager on request.
author = session.get(Author, "alice")
author.articles # lazy — queried on first access
eager = session.get(Author, "alice", fetch=["articles"])
eager.articles # already loaded, no extra query
# Subclass Repository to drop down to typed Cypher when you need it.
class ArticleRepository(Repository[Article]):
def by_author_email(self, email: str) -> list[Article]:
return self.cypher(
"MATCH (:Author {email: $email})-[:AUTHORED]->(a:Article) RETURN a",
{"email": email},
returns=Article,
)
[!TIP] Every pattern above has an async twin.
AsyncSession,AsyncRepository, andAsyncConnectionManagershare the same API for async-first applications.
Migrations
runic.migrate is a migration tool with a CLI for versioned schema evolution. It stores
revision state inside the graph, so there's no external state table to manage.
runic init
runic revision -m "create user index"
Edit the generated file in runic/versions/:
revision = "1975ea83b712"
down_revision = None
def upgrade(op) -> None:
op.create_range_index("User", "email")
def downgrade(op) -> None:
op.drop_range_index("User", "email")
Apply or roll back:
runic upgrade # apply all pending revisions
runic downgrade # roll back one step
runic downgrade 1975e # roll back to a specific revision (prefix is enough)
Baseline an existing graph without re-running anything — introspect, generate a root revision, and stamp it. The generated revision rebuilds the full schema on an empty graph, so it's safe to replay for CI, cloning, or new tenants:
runic baseline -m "baseline" # introspect, generate root revision, stamp it
runic current # verify it is now tracked
runic upgrade head # rebuild full schema on a fresh graph
Programmatic SDK — drive migrations from code, against any backend:
from pathlib import Path
from runic import Runic, init
from runic.migrate.adapters import create_adapter
init(Path("runic/"))
adapter = create_adapter(
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
)
# adapter = create_adapter("neo4j", host="localhost", port=7687,
# database="neo4j", username="neo4j", password="secret")
runic = Runic(adapter, script_location=Path("runic/"))
runic.migrate.upgrade("head")
print("current:", runic.migrate.current())
Graph-RAG
runic.rag turns unstructured text into a knowledge graph and answers questions over it with
citations — chunking, entity/relation extraction, embedding, storage, and hybrid (vector +
fulltext + graph-expansion) retrieval are handled for you. It builds on the same OGM and driver
layer, so it runs on any supported backend. OpenAI is the default for the LLM and embeddings;
point it at Ollama for a fully local run.
from runic.ogm import create_driver
from runic.rag import GraphRAG, Ontology, RagSettings
settings = RagSettings()
driver = create_driver(
"falkordb", host="localhost", port=6379, graph=settings.falkordb_graph
)
# with_defaults() wires the full adapter stack from your environment:
# paragraph chunker, extractor, embedder, resolver, retrievers, reranker, synthesizer.
rag = GraphRAG.with_defaults(driver, settings=settings, ontology=Ontology.default())
rag.bootstrap_schema() # create entity types + indexes (idempotent)
# Ingest: chunk -> extract entities/relations -> embed -> resolve -> write the graph.
rag.ingest_text(
"Ada Lovelace worked with Charles Babbage on the Analytical Engine in London.",
source="inline-demo",
)
# mode="auto" classifies the question and picks the retrieval strategy automatically.
answer = rag.query("Who worked on the Analytical Engine?")
print(answer.text)
for citation in answer.citations:
print(f" - [{citation.source}] {citation.text[:80]}...")
Every stage is a port with a default adapter you can swap — custom ontologies, chunkers,
extractors, or retrievers — and the optional runic-rag-docling add-on plugs Docling in for
layout-aware parsing and chunking of PDFs and office documents.
uv add "runic-py[graphrag,falkordb]" # Graph-RAG extras + a backend driver
[!NOTE] Set
OPENAI_API_KEY(or place it in a local.env) before running, or setRUNIC_RAG_LLM_PROVIDER=ollamaandRUNIC_RAG_EMBEDDING_PROVIDER=ollamato run fully local.
Installation
Install the core package plus the extra for your backend. The core has no graph-driver dependency — you only pull in what you use.
| Backend | Extra | Driver installed |
|---|---|---|
| FalkorDB | falkordb |
falkordb |
| Neo4j | neo4j |
neo4j |
| Memgraph | memgraph |
neo4j (Bolt) |
| ArcadeDB | arcadedb |
neo4j (Bolt) |
| Apache AGE | age |
psycopg[binary] |
| All backends | all |
all of the above |
| Graph-RAG | graphrag |
pydantic-ai, pymupdf (combine with a backend) |
uv add "runic-py[falkordb]" # FalkorDB
uv add "runic-py[neo4j]" # Neo4j
uv add "runic-py[memgraph]" # Memgraph (Bolt)
uv add "runic-py[arcadedb]" # ArcadeDB (Bolt)
uv add "runic-py[age]" # Apache AGE (PostgreSQL extension)
uv add "runic-py[all]" # everything
uv add "runic-py[graphrag,falkordb]" # Graph-RAG SDK + a backend driver
[!NOTE] Runic requires Python 3.14+.
Documentation
Full conceptual overview, async usage, advanced CLI flags, the Graph-RAG guides, and the complete API reference live at the Runic Documentation.
License
Released under the MIT License.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file runic_py-0.5.0.tar.gz.
File metadata
- Download URL: runic_py-0.5.0.tar.gz
- Upload date:
- Size: 189.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a824a80c9f594315be15d628914d85d385273794b46887cb18d5222f5370f314
|
|
| MD5 |
129dc69ebe84072ca07ed1984ca2fec6
|
|
| BLAKE2b-256 |
e3f40f139c41f8853076501dc150c97643405695c0b1354f675b1f0e39bcb514
|
File details
Details for the file runic_py-0.5.0-py3-none-any.whl.
File metadata
- Download URL: runic_py-0.5.0-py3-none-any.whl
- Upload date:
- Size: 245.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
49283095932155bb2a97780de2bea2f6a67333aaf659b9d8ddd30c32036c7c2b
|
|
| MD5 |
6edc30087ca5b4d1233718cbe98691ba
|
|
| BLAKE2b-256 |
ef3bb64773ebed34386079359fb0176ede7081020fcbe25f5f7a63fd56ffc16a
|