API reference
The stable public API for HybridDB — constructor, schema, CRUD, query, search, versioned tables, async wrappers, graph and OLAP facades.
This document describes the stable public API for HybridDB 0.8.x. Everything not documented here (any _-prefixed method or attribute) is internal and may change between minor versions.
Stable: methods documented on this page, constants exported from hybriddb, and the db.graph / db.olap facades. Private: _connect, _process_journal, etc. — use cursor() and process_journal() instead.
Imports
from hybriddb import (
BOOLEAN,
HYBRID,
INTEGER,
JSON,
KEYWORD,
LONGTEXT,
REAL,
SEMANTIC,
TEXT,
Column,
HybridDB,
SearchMode,
)
Constructor
db = HybridDB(
path="./data",
embedding_fn=None,
embedding_model_name=None,
max_chroma_index_gb=5,
auto_rebuild_chroma=False,
)
Directory for the SQLite file and vector store. Created if missing.
None Custom embedding function. Defaults to ChromaDB’s bundled local MiniLM embedding. Use a custom function when you need a specific model or provider.
None Label recorded for the embedding (e.g. "my-model"). Default records chroma:all-MiniLM-L6-v2.
5 Guardrail for local disk usage by the vector index.
False Skips the embedding-model mismatch check on init — use when you deliberately swapped embedding_fn for an existing store.
Schema
db.create_table("docs", {"title": TEXT, "body": LONGTEXT, "tags": JSON})
db.create_table("typed_docs", {"title": Column(TEXT), "body": Column(LONGTEXT)})
db.add_column("docs", "summary", LONGTEXT)
db.rename_column("docs", "summary", "abstract")
db.drop_column("docs", "abstract")
schema = db.get_schema("docs")
tables = db.list_tables()
Public methods validate table and column identifiers — use simple Python identifiers such as docs, messages, content, created_at. Schema changes are rejected on versioned tables.
CRUD
row_id = db.insert("docs", {"title": "Hello", "body": "Hybrid search memory"})
rows = db.insert_batch("docs", [{"title": "A", "body": "..."}, {"title": "B", "body": "..."}])
row = db.get("docs", row_id)
ok = db.update("docs", row_id, {"title": "Updated"})
deleted = db.delete("docs", row_id)
total = db.count("docs")
insert_batch() returns list[int | str] — strings when the table uses id TEXT PRIMARY KEY.
Query
rows = db.query(
"docs",
where="title LIKE ?",
params=("%hello%",),
order_by="title ASC",
limit=100,
)
For custom read-only SQL use read_query(); for migrations or custom writes use raw_query() or the public cursor context manager:
rows = db.read_query("SELECT title FROM docs WHERE title LIKE ?", ("%hello%",))
with db.cursor() as cur:
cur.execute("CREATE INDEX IF NOT EXISTS idx_docs_title ON docs(title)")
Search
db.search(table, column, query, mode="hybrid", where=None, ...)
db.search_all(table, query, ...)
db.search_columns(table, query, ...)
hybrid keyword (BM25 lexical), semantic (vector ANN), or hybrid (RRF fusion of both). Accepts SearchMode enums or strings.
None Scalar-column filters pushed into the Chroma scan before the vector query. Equality ({"user_id": "u2"}) and operators ({"score": {"$gte": 50}}). Keys must be scalar columns mirrored into Chroma metadata.
0.0 Weight given to recency scoring; pair with recency_column.
5 Maximum number of results returned.
0.5 Keyword-vs-semantic weight in the RRF fusion. Measured sweet spot — the accuracy curve is flat within ±0.03 either side, so leave it alone.
None Pre-computed query embedding; skips the embedding call when provided.
Behavior: TEXT columns support keyword; LONGTEXT supports keyword, semantic, and hybrid; hybrid fuses with reciprocal-rank fusion; empty queries return []. Calling search with query=None skips search entirely and returns the latest rows ordered by primary key.
Versioned tables API
Opt in per table: create_table(..., versioned=True, hash_chain=True). Synced via the methods below; upsert() also works on non-versioned tables (plain insert-or-update, pk required in data).
| Method | Purpose |
|---|---|
upsert(table, row) | Insert-or-update; journals delete(old) + add(new) on pk change |
log(table, limit=100) | Change log, newest first |
is_versioned(table) | Whether the table opted into versioned history |
history(table, key=...) | Every version of a row, with hashes |
diff(table, from_seq, to_seq) | Added / removed / changed between two points |
as_of(table, seq) | Point-in-time read |
checkpoint(table, name) | Named restore point |
rollback(table, checkpoint=...) | Rewind — state re-applied as new versions |
verify_chain(table) | Tamper-evidence check → {"valid": True, ...} |
archive(table, path, format=) | Export history to parquet or jsonl |
prune(table, before_seq) | Retention; keeps the chain verifiable |
db.author = "agent-1" | Optional author recorded on every history event |
v0.8.0 Chroma vectors are keyed by the logical primary key, not the physical rowid — one identity across SQLite, the DuckDB mirror, and Chroma. Pre-0.8 collections keep rowid keys until you opt in:
db.migrate_vector_identity(table=None) # re-keys legacy collections;
# copies embeddings (never recomputed), idempotent
Journal and maintenance
db.insert_batch("docs", rows, sync=False) # defer Chroma sync (warns above 5,000 rows)
pending = db.journal_status("docs")
processed = db.process_journal(limit=5000)
health = db.health("docs")
# {"sqlite_rows": 5000, "chroma_docs": {"contacts_bio": 5000}, "status": "ok"}
result = db.reconcile("docs") # repairs missing docs, removes ghosts, refreshes graph state
Maintenance and backup
db.backup(path) # copy the entire database directory atomically
db.restore(path) # replace the current database from a backup
db.vacuum() # reclaim disk space (rebuilds the SQLite file)
report = db.check_integrity() # diagnostics across SQLite, ChromaDB, DuckDB
db.reindex(table=None) # rebuild Chroma + FTS5 + DuckDB from SQLite data
db.force_rebuild_chroma_index() # drop and rebuild the Chroma index
db.stats() # size and count statistics for all storage layers
db.close() # close handles
Portable SQL export/import (FTS5 is excluded from dumps and rebuilt on import):
db.export_sql("dump.sql")
db.import_sql("dump.sql")
Async API
Async wrappers run blocking SQLite/ChromaDB work in worker threads — useful in FastAPI and other async apps.
await db.acreate_table("messages", {"content": LONGTEXT})
row_id = await db.ainsert("messages", {"content": "hello from async"})
row = await db.aget("messages", row_id)
results = await db.asearch("messages", "content", "hello")
total = await db.acount("messages")
await db.aclose()
Available: acreate_table, aadd_column, adrop_column, arename_column, ainsert, ainsert_batch, aupdate, adelete, aget, aquery, aread_query, araw_query, acount, asearch, asearch_all, ahealth, areconcile, aprocess_journal, aclose.
HybridDB uses an internal RLock around SQLite and DuckDB access. For high-write workloads prefer insert_batch(…, sync=False) plus process_journal(). One store per process — a second process should open the SQLite file read-only (WAL allows concurrent readers); DuckDB mirrors are per-process and rebuilt cheaply on demand.
Graph API
Synced node ids are namespaced by table — {table}:{pk} (e.g. docs:1) — manual edges between synced nodes must use namespaced ids.
alice = db.graph.add_node(label="Alice", type="person") # auto-generated id
bob = db.graph.add_node("bob-1", label="Bob", type="person")
db.graph.add_edge(None, alice, bob, edge_type="knows", weight=0.9)
neighbors = db.graph.get_neighbors(alice, direction="both")
path = db.graph.shortest_path(alice, bob)
scores = db.graph.pagerank() # standard PageRank
scores = db.graph.pagerank(personalization={"docs:1": 1.0}, alpha=0.85)
# semantic graph retrieval: vector-search seeds, then expand via PageRank
ppr = db.graph.search_graph_ppr("memory", hop_expansion=2, limit=5)
# spread from 20 seeds but return top-5
ppr = db.graph.search_graph_ppr("memory", k_seeds=20, limit=5)
synced = db.graph.sync_graph_nodes() # refreshes labels, removes ghost nodes
| Method | Purpose |
|---|---|
add_node(id=None, label="", **kw) / add_nodes(nodes) | Create nodes (re-adding an existing id preserves its edges) |
get_node(id) / update_node(id, data) / delete_node(id) | Node lifecycle |
list_nodes(...) | Enumerate nodes |
add_edge(id=None, source, target, type="relates_to", weight=1.0, properties=None, valid_until=None) / add_edges(edges) | Edges with optional expiry |
get_edge(id) / get_edges(source=, target=, type=, limit=) / update_edge(id, data) / delete_edge(id) | Edge lifecycle |
neighbors(id, direction="both", type=None) | Adjacency |
traverse(start_id, max_depth=3, direction="out", type=None, max_cost=3.0) | Recursive CTE traversal with cost cap |
shortest_path(source, target) | Weighted shortest path |
pagerank(personalization=None, alpha=0.85) | Standard or personalized PageRank |
betweenness_centrality() / community_detect() / connected_components() | NetworkX algorithms |
decay_edges() | Age-based edge decay |
to_networkx(directed=True) | Export for custom algorithms |
search_graph(query, hop_expansion=2, limit=10) | Vector seeds + neighbor expansion |
Graph-aware semantic retrieval — vector seeds, subgraph expansion, then Personalized PageRank:
ppr = db.graph.search_graph_ppr(
"memory",
hop_expansion=2, # traversal depth from seeds
limit=10, # final results
alpha=0.15, # damping: lower = more concentrated near seeds
min_similarity=0.0, # seed filter (keep 0.0 for MiniLM — distances hover near 1.0)
k_seeds=None, # separate seed count from result count
)
Auto-sync rules let table rows become graph nodes/edges automatically:
db.graph.register_entity_node("docs", type="entity", id_column="id",
label_template="docs: {title}")
db.graph.register_edge_rule("messages", "docs", edge_type="mentions")
db.graph.sync_graph_nodes()
Synced ids are namespaced (docs:1) and label templates render every {column} placeholder from the row. search_graph_ppr runs PageRank on the undirected subgraph — consistent with its direction=“both” traversal. Edge rules use each table’s real primary key column.
The db.graph facade exists for discoverability; direct methods (db.add_node(), db.shortest_path()) remain supported.
OLAP API (optional)
pip install "hybriddb[analytics]"
db.create_table("events", {"category": TEXT, "value": REAL})
db.insert_batch("events", [{"category": "A", "value": 1.5}], sync=False)
rows = db.olap.query("SELECT category, SUM(value) AS total FROM events GROUP BY category")
The db.olap facade auto-registers app tables with DuckDB before queries — mirrors are created lazily on first OLAP use, so tables you never query cost nothing to maintain. Direct methods: register_duckdb_table(table), sync_duckdb_table(table), analytics(sql).
Long-document chunking API
from hybriddb.chunking import chunk_text
chunks = chunk_text(full_text) # ~1200 chars ≈ 300 tokens for MiniLM-class models
chunks = chunk_text(full_text, overlap=True) # prepends the previous chunk's final sentence
See Core concepts for the chunks-as-rows ingestion pattern.
Source of truth for this page: HybridDB · open-assistants-lab/HybridDB