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.

Public vs private

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,
)
path str REQUIRED

Directory for the SQLite file and vector store. Created if missing.

embedding_fn callable OPTIONAL default: None

Custom embedding function. Defaults to ChromaDB’s bundled local MiniLM embedding. Use a custom function when you need a specific model or provider.

embedding_model_name str OPTIONAL default: None

Label recorded for the embedding (e.g. "my-model"). Default records chroma:all-MiniLM-L6-v2.

max_chroma_index_gb int OPTIONAL default: 5

Guardrail for local disk usage by the vector index.

force_model bool OPTIONAL default: 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()
Identifier rules

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)")
db.search(table, column, query, mode="hybrid", where=None, ...)
db.search_all(table, query, ...)
db.search_columns(table, query, ...)
mode str OPTIONAL default: hybrid

keyword (BM25 lexical), semantic (vector ANN), or hybrid (RRF fusion of both). Accepts SearchMode enums or strings.

where dict OPTIONAL default: 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.

recency_weight real OPTIONAL default: 0.0

Weight given to recency scoring; pair with recency_column.

limit int OPTIONAL default: 5

Maximum number of results returned.

fts_weight real OPTIONAL default: 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.

query_embedding list OPTIONAL default: 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).

MethodPurpose
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.

Thread safety

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
MethodPurpose
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()
Namespaced ids + undirected PPR

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