hopai compiles multi-hop graph traversals into a single recursive CTE against
two ordinary PostgreSQL tables (nodes, edges, each with a JSONB
properties bag) — no extension, no sidecar service. Traversal, ingestion,
updates, real constraints and vector search, through a Python API, a JSON
one, and a Cypher subset, so an LLM agent and a human developer can both use
it with nothing new to learn.
Early and moving. hopai is pre-1.0 (
0.0.x). Everyfeat/fixrelease may change an interface — a method signature, a JSON key, a refusal's exact wording — without a deprecation cycle; pin a version if that would break you. Schema migrations don't exist yet either:drop_schema()+create_schema()is the upgrade path for now. See the changelog before bumping.
⚡ Quick start
pip install hopai
from sqlalchemy import create_engine
from hopai import Graph, Start, Hop
graph = Graph(create_engine("postgresql+psycopg2://user:pass@host/db"))
# "Which companies do Alice's friends work for — counting friends of
# friends, up to four hops out, and only people who are still active?"
result = graph.traverse(
Start(where={"name": "Alice"}),
Hop(via={"kind": "friend"}, hops=(1, 4), where={"active": True}),
Hop(via={"kind": "works_at"}, where={"type": "company"}),
)
result.nodes # [{"id": "1", "properties": {...}}, ...]
result.edges # [{"id": "7", "start_id": "1", "end_id": "2", "properties": {...}}, ...]
result.to_networkx() # in-memory graph, if you have networkx installed
Read a traversal left to right as a sentence: start here → follow these
edges this many times → land on nodes like this → then again. You get
back the whole matching subgraph, not just the endpoints. The full walkthrough
is 01_quickstart.
🔭 Every way to ask
One engine underneath all of it — the same "Alice's friends" question as above, other front ends that ask it, and the other things this engine answers:
# JSON -- for an LLM tool call, an HTTP handler, or config-driven traversal
traverse_json(graph, {"start": {"where": {"name": "Alice"}},
"hops": [{"via": {"kind": "friend"}, "hops": [1, 4]}]})
# Cypher -- for a caller, or a model, that already thinks in it
graph.cypher("MATCH (a:person {name: 'Alice'})-[:friend*1..4]->(b) RETURN b")
# Aggregate -- a number instead of a subgraph, computed in the database
graph.aggregate(Start(where={"name": "Alice"}),
Hop(via={"kind": "friend"}, hops=(1, 4)),
aggregates={"count": Count()}) # {"count": 12}
# Change and delete -- the same filters, selecting rows to update or remove
graph.update_nodes(where={"name": "Alice"}, set={"active": False})
Similarity and traversal compose — find the nodes closest in meaning to some text, then walk the graph from there. No separate vector database, no gluing two systems together with application code:
# Vector search on its own -- exact cosine similarity, no pgvector, no extension
graph.vector_search(Near("summary", "distributed consensus"), k=10,
where={"type": "paper"})
# Start a traversal from similarity instead of a property match: the 5 papers
# most similar to the text, then everything they cite, up to 3 hops out.
graph.traverse(
Start(near=Near("summary", "distributed consensus"), keep=5),
Hop(via={"kind": "cites"}, hops=(1, 3)),
)
Hop(near=, keep=) and Hop(via_near=, via_keep=) do the same thing
mid-chain — rank what a hop reaches, or beam over each node's edges, by
similarity instead of only filtering by property. See
Vector search
in the table below.
Reranking is the third stage — retrieve wide and cheap with the cosine
above, then actually read each candidate against the query before keeping
k. It attaches to a flat search and to a traversal step alike:
import cohere
from hopai import Rerank
reader = Rerank(cohere.ClientV2(), model="rerank-v3.5",
document_from='.properties.title + ": " + .properties.summary',
candidates=50) # pool the reranker sees
graph.vector_search(Near("summary", "distributed consensus"), rerank=reader, k=10)
No provider package imported here either — a Cohere/Voyage client, a
sentence-transformers CrossEncoder, or a plain callable all work. See
Reranking in
the table below.
Every one of these compiles through the same query builder, so the SQL, the semantics and the invariants are identical no matter which front end wrote the call. The "Learn more" table below is where each one's full reference lives.
✨ Highlights
- 🐘 Plain PostgreSQL — two tables, a recursive CTE, no extension, no new operational dependency.
- 🧭 Real multi-hop traversal — bounded and unbounded hops, per-hop
direction,
OPTIONAL, rich JSONB filtering, one round trip. - ✏️ Update and delete by filter —
SET/REMOVE/DETACH DELETEsemantics, with a filterless call refusing rather than emptying the graph. - 🧮 In-database aggregation —
count/sum/avg/min/maxcomputed where the data lives. - 🧬 Many graphs, one database — a graph is a string, not a schema; cross-graph edges are impossible by construction (composite FK).
- 🤖 Three front ends, one engine — Python, JSON (with a ready-made LLM tool schema), and a Cypher subset all compile through the same builder.
- 🔌 An MCP server in one command —
hopai-mcpexposes reading, writing, schema and similarity tools, with permissions deciding which tools exist. - 🔐 Constraints Neo4j puts behind an enterprise licence — unique, composite, partial, existence, type and CHECK constraints on JSONB.
- 🧲 Similarity-seeded traversal — find the nodes closest in meaning to
some text, then walk the graph from there, in one call. Exact cosine
similarity on plain
real[]columns, no pgvector, multivector queries, and a field-levelembed=so you can hand it text instead of floats. - 🎯 Reranking, including inside the walk —
Rerank(client, document_from='<jq>')adds a third retrieval stage to a search and to a traversal step, where a candidate is a node plus how it was reached. A model may write the projection: it's validated against a total jq subset in whichenvdoesn't parse. - 🧪 Tested like it matters — an 85% coverage gate and mutation testing
in CI, and real benchmark numbers in
benchmarks/.
🗄️ Schema
graph.create_schema() # idempotent; safe to call on every start-up
CREATE TABLE nodes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
properties JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE edges (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
start_id BIGINT NOT NULL REFERENCES nodes(id),
end_id BIGINT NOT NULL REFERENCES nodes(id),
properties JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX ON edges (start_id);
CREATE INDEX ON edges (end_id);
CREATE INDEX ON nodes USING GIN (properties);
CREATE INDEX ON edges USING GIN (properties);
Custom table/column names, and extra real columns alongside the JSONB bag
(a foreign key to your own users table, with the collision refusals that
keep it distinct from a JSONB property) are covered in full in
Schema; multi-graph
isolation on one connection pool in
07_many_graphs and
Many graphs.
📚 Learn more
Nothing below is summarized away — every section the README used to spell
out inline now has a full write-up in one of three places, and each links
to the others: a runnable notebook for the topics that have one
(executed in CI on every PR, so it can't drift from the API), a guide
under Reference explaining the semantics and the gotchas a notebook doesn't
narrate, and a generated API reference — hopai.core
for Graph itself, and one page per module for everything else — built
straight from the library's own docstrings and signatures, so it is the one
tier of this table that is structurally unable to go stale:
| Topic | Notebook | Full reference |
|---|---|---|
| Schema — two tables, extending the model with real columns | 08_under_the_hood |
Schema |
| Many graphs, one database | 07_many_graphs |
Many graphs |
Getting data in — add_nodes/add_edges/merge_*/ingest |
— | Getting data in |
Changing and deleting — update_*/delete_*/clear/mutate |
— | Changing and deleting |
| Constraints — unique, composite, partial, existence, type, CHECK | 05_constraints |
Constraints |
| Declaring, inferring and enforcing a graph schema | 06_graph_schema |
Graph schema |
Filters — AND/OR/NOT/GT/BETWEEN, the escape hatch |
— | Filters |
Traversal: direction, hop count, OPTIONAL |
02_traversal |
Traversal |
| Aggregation | 03_aggregation |
Aggregation |
| Vector search, hybrid ranking, text-to-vector embedding | 09_vector_search |
Vector search |
| Reranking — the third retrieval stage, including step-wise | 10_reranking |
Reranking |
| The JSON interface | 04_json_and_cypher |
JSON interface |
| Cypher as input syntax | 04_json_and_cypher |
Cypher |
| What this doesn't do (yet), and why each refusal is a refusal | — | Limits |
| MCP server — client setup, every tool, every flag | — | Full guide |
| Read/write pipelines, multi-graph internals, gotchas | — | architecture.md |
| Fixtures, coverage gate, mutation testing | — | testing.md |
| release-please, PyPI trusted publishing | — | releasing.md |
| Measured traversal and vector-search costs | — | benchmarks/README.md |
See notebooks/README.md for how to run the
notebooks yourself against a throwaway database.
🔌 MCP server
The same graph as an MCP server, so Claude Desktop, Claude Code, an IDE or an agent framework can use it with nothing to write:
pip install "hopai[mcp]"
hopai-mcp --dsn postgresql+psycopg2://user:pass@localhost/db --read-only
Eleven tools — traverse, aggregate, Cypher, ingest, update/delete, schema
inference/declaration, and similarity search. Permissions decide which
tools exist: --read-only registers reading only, the default adds
writing, --allow-mutations adds deleting, --allow-ddl adds
enforce_schema. 📖 Full guide.
⏱️ Async
AsyncGraph (pip install hopai[asyncio]) covers traversal, aggregation,
ingestion, mutation and vector search for an async app — the same query
builders Graph runs, reached through SQLAlchemy's sync/async bridge.
Schema and constraint declaration stay on the sync Graph — one-time setup
calls with no concurrency to gain. See hopai/asyncio.py and the
Async section
of architecture.md for the bridge design and the benchmark behind it.
🚧 What this doesn't do (yet)
- No disjoint multi-pattern matching — one linear chain of hops only.
OPTIONALonly on the last hop, not mid-chain.- Aggregation covers
count/sum/avg/min/maxover the last step's matched nodes, numeric properties only — no grouping, nostddev/percentiles. - Deletes and updates select rows by their properties, never by where a traversal arrived.
- Vector search is exact and unindexed by design — no ANN, no late-interaction multivectors.
- Embedding retries transient failures but does not cache or rate-limit — that's the application's and the client's job, respectively.
- A cycle-protection path array on every recursive row is measurably not-cheap past roughly 10 hops on a single-segment traversal.
Each refusal names the rewrite rather than approximating — see
the full list
for the reasoning behind each one, and
architecture.md /
hopai/vectors.py/hopai/cypher.py for the implementation.
🛠️ Development
pip install -e ".[dev]"
docker compose up -d # throwaway PostgreSQL matching the default DSN
pytest tests/ -v
ruff check .
Most of the suite needs no database — query shape, filter compilation and the Cypher translator are tested against compiled SQL. CI enforces an 85% line coverage floor and runs mutation testing on every PR. See testing.md.
📄 License
MIT — see 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 hopai-0.1.0.tar.gz.
File metadata
- Download URL: hopai-0.1.0.tar.gz
- Upload date:
- Size: 609.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a05c3341aa901e88bcdd9906d14daa99c7370b0ea475ac1578f3fa24327488c
|
|
| MD5 |
218b072b00138c3aa57fe1268f6c6d0a
|
|
| BLAKE2b-256 |
464958a0fb123ecb8d9b14e18a73a62bcdc6137c9076d57eaf7f3c0005edf390
|
Provenance
The following attestation bundles were made for hopai-0.1.0.tar.gz:
Publisher:
release.yml on alexbojko/hopai
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hopai-0.1.0.tar.gz -
Subject digest:
3a05c3341aa901e88bcdd9906d14daa99c7370b0ea475ac1578f3fa24327488c - Sigstore transparency entry: 2499793962
- Sigstore integration time:
-
Permalink:
alexbojko/hopai@519ef65dff62998dff95e4824a71097226625e09 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/alexbojko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@519ef65dff62998dff95e4824a71097226625e09 -
Trigger Event:
push
-
Statement type:
File details
Details for the file hopai-0.1.0-py3-none-any.whl.
File metadata
- Download URL: hopai-0.1.0-py3-none-any.whl
- Upload date:
- Size: 303.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
94831f3c8e45cc72cf66b8858c4818f4d63569a78101540baacb6a972bed93ed
|
|
| MD5 |
57137e40b64919fcdda36a18bfa80643
|
|
| BLAKE2b-256 |
b4761c267b3173bd7f9f0bd1d9a5d6da81bb952afb1e0e53208b7cbd086a9466
|
Provenance
The following attestation bundles were made for hopai-0.1.0-py3-none-any.whl:
Publisher:
release.yml on alexbojko/hopai
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hopai-0.1.0-py3-none-any.whl -
Subject digest:
94831f3c8e45cc72cf66b8858c4818f4d63569a78101540baacb6a972bed93ed - Sigstore transparency entry: 2499793968
- Sigstore integration time:
-
Permalink:
alexbojko/hopai@519ef65dff62998dff95e4824a71097226625e09 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/alexbojko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@519ef65dff62998dff95e4824a71097226625e09 -
Trigger Event:
push
-
Statement type: