GraphMemory
Embedded GraphRAG database built on DuckDB. Single Python package — no server, no external services. Ships vector (HNSW), full-text (BM25), hybrid search, fluent query builder, multi-hop traversal, fuzzy dedup, DSPy extraction, NetworkX algorithms, and a zero-dep D3.js visualizer.
When to reach for this
- Knowledge graph with semantic search (not just a vector DB, not just a graph DB).
- RAG where graph traversal is part of retrieval.
- Extract entities/relations from text and store them durably with dedup.
- Prototyping — file-backed or in-memory graph without Neo4j/Postgres/pgvector.
Do not use when: user already has Neo4j/Neptune/ArangoDB, or scale is hundreds of millions of nodes — GraphMemory is DuckDB-embedded, single-writer.
Install
pip install graphmemory
pip install graphmemory[extraction] # DSPy entity/relation extraction
pip install graphmemory[algorithms] # NetworkX algorithms
Decision table
| User intent |
Method |
| Insert one node |
graph.insert_node(node) |
| Bulk insert |
graph.bulk_insert_nodes(nodes) |
| Insert-or-update by property |
graph.merge_node(node, match_keys=["name"]) |
| Fuzzy insert-or-update |
graph.merge_node(node, match_keys=["name"], similarity_threshold=0.9) |
Dedupe edges on (src, tgt, relation) |
graph.merge_edge(edge) |
| Clean up existing duplicates |
graph.resolve_duplicates(match_keys=["name"], similarity_threshold=0.9) |
| Pure vector kNN |
graph.nearest_nodes(vector, limit) |
| Pure BM25 text |
graph.search_nodes(query, limit) |
| Combined text + vector |
graph.hybrid_search(query, query_vector, text_weight, vector_weight) |
| Lookup by property |
graph.nodes_by_attribute("name", "Alice") |
| Direct neighbors |
graph.connected_nodes(node_id) |
| Multi-hop traversal |
graph.query().traverse(source_id=id, depth=2).execute() |
| Filtered query |
graph.query().match(type="Person").where(role="eng").execute() |
| GraphRAG context assembly |
graph.retrieve(query, query_vector, max_hops, max_tokens) |
| End-to-end Q&A |
graph.ask(query, query_vector, llm_callable=fn) |
| Extract + store from text |
extract_and_merge(graph, text, match_keys=["name"]) |
| Extract in parallel across chunks |
extract_and_merge_parallel(graph, chunks, max_workers=8) |
| PageRank / centrality |
pagerank(graph), betweenness_centrality(graph) |
| Atomic block |
with graph.transaction(): ... |
| Browser visualization |
graph.visualize() |
Canonical snippets
Init
from graphmemory import GraphMemory, Node, Edge, MergeStrategy
# database=None is in-memory; pass a path for persistence.
# vector_length and distance_metric are fixed at init time.
graph = GraphMemory(
database="graph.db",
vector_length=1536, # must match your embedding model
distance_metric="cosine", # "l2" | "cosine" | "inner_product"
hnsw_ef_construction=128,
hnsw_ef_search=64,
hnsw_m=16,
auto_index=True, # HNSW auto-built on init
max_retries=3, # transient IO error retry
)
Insert + merge
alice = Node(type="Person", properties={"name": "Alice"}, vector=embed("Alice"))
bob = Node(type="Person", properties={"name": "Bob"}, vector=embed("Bob"))
graph.insert_node(alice)
graph.insert_node(bob)
graph.insert_edge(Edge(source_id=alice.id, target_id=bob.id, relation="reports_to"))
# Idempotent re-ingest on a natural key
graph.merge_node(alice, match_keys=["name"])
# Fuzzy merge — tolerates "Alice Smith" vs "alice smith"
graph.merge_node(
alice,
match_keys=["name"],
similarity_threshold=0.9, # Jaro-Winkler threshold (1.0 = exact)
vector_threshold=0.2, # optional cosine distance cap
match_type=True, # also require same `type`
strategy=MergeStrategy.UPDATE, # UPDATE | REPLACE | KEEP
)
Hybrid search
results = graph.hybrid_search(
query_text="who leads ML?",
query_vector=embed("who leads ML?"),
text_weight=0.5,
vector_weight=0.5,
limit=10,
)
for r in results:
print(r.score, r.node.properties)
GraphRAG
# Context-only (own the prompt)
result = graph.retrieve(
query=q, query_vector=qv,
max_hops=2, max_tokens=4000, search_limit=10,
)
print(result.context_text, result.token_estimate, result.seed_node_count, result.total_node_count)
# End-to-end — llm_callable signature: (system_prompt, user_prompt) -> str
def my_llm(system, user):
return openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
).choices[0].message.content
answer = graph.ask(query=q, query_vector=qv, llm_callable=my_llm)
print(answer["answer"])
Pass llm_callable=None to get retrieval-only output — useful to inspect the context before wiring an LLM.
Query builder
# Filter by type + property
engineers = graph.query().match(type="Person").where(role="engineer").execute()
# Multi-hop traversal — returns TraversalResult with depth + path
two_hop = graph.query().traverse(source_id=alice.id, depth=2).execute()
# Paginate + order
page = graph.query().match(type="Person").order_by("name").limit(20).offset(40).execute()
# Return edges instead of nodes
edges = graph.query().match(type="Person").edges().execute()
DSPy extraction
import dspy
from graphmemory.extraction import extract_and_merge, extract_and_merge_parallel
dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))
# Single pass
node_results, edge_results = extract_and_merge(
graph, text, match_keys=["name"], similarity_threshold=0.88,
)
# Parallel across chunks — two phases: nodes first (all chunks), then edges
# with the full node context. Saturates your RPM.
node_results, edge_results = extract_and_merge_parallel(
graph,
chunks=paragraph_chunks,
match_keys=["name"],
similarity_threshold=0.88,
max_workers=8, # match your provider's RPM headroom
phase, done, total: print(f"{phase}: {done}/{total}"),
)
Transactions
with graph.transaction():
graph.insert_node(a)
graph.insert_node(b)
graph.insert_edge(Edge(source_id=a.id, target_id=b.id, relation="x"))
# Exception inside the block → ROLLBACK. Clean exit → COMMIT.
Advanced patterns
Two-pass dedup (idiomatic)
Extract with a loose threshold, then clean up with a tighter one. This is the pattern in examples/test_ingest.py.
# Pass 1 — during ingest, be permissive to avoid fragmenting entities
extract_and_merge_parallel(graph, chunks, similarity_threshold=0.88, max_workers=50)
# Pass 2 — after ingest, resolve residual duplicates more strictly
clusters = graph.resolve_duplicates(
match_keys=["name"],
match_type=True,
similarity_threshold=0.9,
vector_threshold=0.15,
)
for c in clusters:
print(f"Kept {c.survivor.properties['name']}, merged {len(c.merged)} dups")
resolve_duplicates picks the first-seen node as survivor, reassigns all incoming/outgoing edges to it, and deletes the rest. Self-loops from the reassignment are dropped.
Custom chunking + sequential linking
Pattern from examples/lexical_graph.py:
prev = None
for chunk in chunks:
node = Node(type="Chunk", properties={"text": chunk}, vector=embed(chunk))
graph.insert_node(node)
if prev is not None:
graph.insert_edge(Edge(source_id=prev.id, target_id=node.id, relation="followed_by"))
prev = node
Inspect before asking
result = graph.retrieve(query=q, query_vector=qv, max_hops=2, max_tokens=4000)
print(result.context_text) # See exactly what the LLM would receive
# Tune max_hops / max_tokens / search_limit before wiring ask()
Gotchas
vector_length and distance_metric are locked at init. Swapping embedding models means a new database. Valid metrics: "l2", "cosine", "inner_product".
- Missing vectors are silently zero-filled in
insert_node — bulk_insert_nodes skips nodes whose vectors don't match vector_length and logs a warning. Validate upstream if correctness matters.
- HNSW is auto-built on init (
auto_index=True). Tune via hnsw_ef_construction, hnsw_ef_search, hnsw_m. Call graph.compact_index() after heavy deletes to reclaim space (also called automatically by delete_node).
- FTS index is lazy — first
search_nodes/hybrid_search call after writes rebuilds it. Expect first-search latency. Force a rebuild with graph.reindex() if you want it warm before traffic.
- Edge dedup key is
(source_id, target_id, relation). Relations are normalized (lowercased, underscored) before comparison — "Reports To" and "reports_to" collide. Edge properties are NOT part of the key.
delete_node cascades edges in both directions (as source AND as target). No orphan-edge safety net.
merge_node strategies — UPDATE shallow-merges dicts (incoming wins on collision), REPLACE overwrites wholesale, KEEP only inserts if new. Pick intentionally.
similarity_threshold=1.0 is exact match (the default). Lower it to enable Jaro-Winkler fuzzy matching on string properties. Non-string properties always use JSON equality.
match_type=True (default) requires same type for merge. Set False to merge across types — rarely what you want.
resolve_duplicates is O(n²)-ish in fuzzy mode. For large graphs, narrow with match_type and a tight vector_threshold first.
extraction and algorithms are optional extras. Wrap imports in try/except or check pip show before recommending code that depends on them.
- Single-writer DuckDB. Connection pooling and
@with_retry (exponential backoff on transient IO errors) are built in, but don't open the same file from multiple processes for concurrent writes.
cursor() returns independent cursors for concurrent reads; the main connection is RLock-guarded for writes.
ask() with llm_callable=None returns retrieval only — no generation. Always use this first to validate context before paying for LLM calls.
Data models
| Model |
Key fields |
Node |
id: UUID, type: str | None, properties: dict, vector: list[float] |
Edge |
id, source_id, target_id, relation: str, weight: float | None |
SearchResult |
node, score (higher = better for both BM25 and hybrid) |
NearestNode |
node, distance (lower = closer) |
TraversalResult |
node, depth, path: list[UUID] |
RetrievalContext |
node, relationships: list[dict], hop_distance: int |
RetrievalResult |
query, contexts, context_text, token_estimate, seed_node_count, total_node_count |
MergeResult |
node, created: bool (True = inserted, False = updated) |
EdgeMergeResult |
edge, created: bool |
DuplicateCluster |
survivor: Node, merged: list[Node] |
All models are Pydantic. IDs auto-generate as UUIDs.
Examples in the repo
examples/openai_example.py — OpenAI embeddings, similarity search, attribute lookup
examples/lexical_graph.py — chunked Wikipedia text with SentenceTransformer, sequential followed_by edges
examples/dspy_example_typed_pred.py — DSPy typed-predictor extraction
examples/test_ingest.py — parallel extraction (50 workers, 0.88 threshold) + post-pass resolve_duplicates at 0.90
Read examples/test_ingest.py before building a real ingest pipeline — it's the template.
Testing
python3 -m pytest tests/tests.py -v
296 tests cover the public API. Run them when modifying the library.
Source: bradAGI/GraphMemory — distributed by TomeVault.
1---2name: graphmemory3description: Build and query embedded GraphRAG knowledge graphs with DuckDB-backed vector, full-text, and hybrid search. Use when the user wants to store entities and relations, run RAG over a graph, extract knowledge graphs from text with DSPy, run graph algorithms (PageRank, centrality, components), merge/upsert nodes and edges, fuzzy-dedupe an existing graph, or visualize interactively in a browser. Trigger phrases include "knowledge graph", "GraphRAG", "graph database", "hybrid search", "extract entities and relations", "DuckDB graph", "embedded graph store", "dedupe graph nodes". Use when this capability is needed.4---56# GraphMemory78Embedded GraphRAG database built on DuckDB. Single Python package — no server, no external services. Ships vector (HNSW), full-text (BM25), hybrid search, fluent query builder, multi-hop traversal, fuzzy dedup, DSPy extraction, NetworkX algorithms, and a zero-dep D3.js visualizer.910## When to reach for this1112- Knowledge graph with semantic search (not just a vector DB, not just a graph DB).13- RAG where graph traversal is part of retrieval.14- Extract entities/relations from text and store them durably with dedup.15- Prototyping — file-backed or in-memory graph without Neo4j/Postgres/pgvector.1617**Do not use** when: user already has Neo4j/Neptune/ArangoDB, or scale is hundreds of millions of nodes — GraphMemory is DuckDB-embedded, single-writer.1819## Install2021```sh22pip install graphmemory23pip install graphmemory[extraction] # DSPy entity/relation extraction24pip install graphmemory[algorithms] # NetworkX algorithms25```2627## Decision table2829| User intent | Method |30|---|---|31| Insert one node | `graph.insert_node(node)` |32| Bulk insert | `graph.bulk_insert_nodes(nodes)` |33| Insert-or-update by property | `graph.merge_node(node, match_keys=["name"])` |34| Fuzzy insert-or-update | `graph.merge_node(node, match_keys=["name"], similarity_threshold=0.9)` |35| Dedupe edges on `(src, tgt, relation)` | `graph.merge_edge(edge)` |36| Clean up existing duplicates | `graph.resolve_duplicates(match_keys=["name"], similarity_threshold=0.9)` |37| Pure vector kNN | `graph.nearest_nodes(vector, limit)` |38| Pure BM25 text | `graph.search_nodes(query, limit)` |39| Combined text + vector | `graph.hybrid_search(query, query_vector, text_weight, vector_weight)` |40| Lookup by property | `graph.nodes_by_attribute("name", "Alice")` |41| Direct neighbors | `graph.connected_nodes(node_id)` |42| Multi-hop traversal | `graph.query().traverse(source_id=id, depth=2).execute()` |43| Filtered query | `graph.query().match(type="Person").where(role="eng").execute()` |44| GraphRAG context assembly | `graph.retrieve(query, query_vector, max_hops, max_tokens)` |45| End-to-end Q&A | `graph.ask(query, query_vector, llm_callable=fn)` |46| Extract + store from text | `extract_and_merge(graph, text, match_keys=["name"])` |47| Extract in parallel across chunks | `extract_and_merge_parallel(graph, chunks, max_workers=8)` |48| PageRank / centrality | `pagerank(graph)`, `betweenness_centrality(graph)` |49| Atomic block | `with graph.transaction(): ...` |50| Browser visualization | `graph.visualize()` |5152## Canonical snippets5354### Init5556```python57from graphmemory import GraphMemory, Node, Edge, MergeStrategy5859# database=None is in-memory; pass a path for persistence.60# vector_length and distance_metric are fixed at init time.61graph = GraphMemory(62 database="graph.db",63 vector_length=1536, # must match your embedding model64 distance_metric="cosine", # "l2" | "cosine" | "inner_product"65 hnsw_ef_construction=128,66 hnsw_ef_search=64,67 hnsw_m=16,68 auto_index=True, # HNSW auto-built on init69 max_retries=3, # transient IO error retry70)71```7273### Insert + merge7475```python76alice = Node(type="Person", properties={"name": "Alice"}, vector=embed("Alice"))77bob = Node(type="Person", properties={"name": "Bob"}, vector=embed("Bob"))78graph.insert_node(alice)79graph.insert_node(bob)80graph.insert_edge(Edge(source_id=alice.id, target_id=bob.id, relation="reports_to"))8182# Idempotent re-ingest on a natural key83graph.merge_node(alice, match_keys=["name"])8485# Fuzzy merge — tolerates "Alice Smith" vs "alice smith"86graph.merge_node(87 alice,88 match_keys=["name"],89 similarity_threshold=0.9, # Jaro-Winkler threshold (1.0 = exact)90 vector_threshold=0.2, # optional cosine distance cap91 match_type=True, # also require same `type`92 strategy=MergeStrategy.UPDATE, # UPDATE | REPLACE | KEEP93)94```9596### Hybrid search9798```python99results = graph.hybrid_search(100 query_text="who leads ML?",101 query_vector=embed("who leads ML?"),102 text_weight=0.5,103 vector_weight=0.5,104 limit=10,105)106for r in results:107 print(r.score, r.node.properties)108```109110### GraphRAG111112```python113# Context-only (own the prompt)114result = graph.retrieve(115 query=q, query_vector=qv,116 max_hops=2, max_tokens=4000, search_limit=10,117)118print(result.context_text, result.token_estimate, result.seed_node_count, result.total_node_count)119120# End-to-end — llm_callable signature: (system_prompt, user_prompt) -> str121def my_llm(system, user):122 return openai_client.chat.completions.create(123 model="gpt-4o-mini",124 messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],125 ).choices[0].message.content126127answer = graph.ask(query=q, query_vector=qv, llm_callable=my_llm)128print(answer["answer"])129```130131Pass `llm_callable=None` to get retrieval-only output — useful to inspect the context before wiring an LLM.132133### Query builder134135```python136# Filter by type + property137engineers = graph.query().match(type="Person").where(role="engineer").execute()138139# Multi-hop traversal — returns TraversalResult with depth + path140two_hop = graph.query().traverse(source_id=alice.id, depth=2).execute()141142# Paginate + order143page = graph.query().match(type="Person").order_by("name").limit(20).offset(40).execute()144145# Return edges instead of nodes146edges = graph.query().match(type="Person").edges().execute()147```148149### DSPy extraction150151```python152import dspy153from graphmemory.extraction import extract_and_merge, extract_and_merge_parallel154155dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))156157# Single pass158node_results, edge_results = extract_and_merge(159 graph, text, match_keys=["name"], similarity_threshold=0.88,160)161162# Parallel across chunks — two phases: nodes first (all chunks), then edges163# with the full node context. Saturates your RPM.164node_results, edge_results = extract_and_merge_parallel(165 graph,166 chunks=paragraph_chunks,167 match_keys=["name"],168 similarity_threshold=0.88,169 max_workers=8, # match your provider's RPM headroom170 on_progress=lambda phase, done, total: print(f"{phase}: {done}/{total}"),171)172```173174### Transactions175176```python177with graph.transaction():178 graph.insert_node(a)179 graph.insert_node(b)180 graph.insert_edge(Edge(source_id=a.id, target_id=b.id, relation="x"))181# Exception inside the block → ROLLBACK. Clean exit → COMMIT.182```183184## Advanced patterns185186### Two-pass dedup (idiomatic)187188Extract with a loose threshold, then clean up with a tighter one. This is the pattern in `examples/test_ingest.py`.189190```python191# Pass 1 — during ingest, be permissive to avoid fragmenting entities192extract_and_merge_parallel(graph, chunks, similarity_threshold=0.88, max_workers=50)193194# Pass 2 — after ingest, resolve residual duplicates more strictly195clusters = graph.resolve_duplicates(196 match_keys=["name"],197 match_type=True,198 similarity_threshold=0.9,199 vector_threshold=0.15,200)201for c in clusters:202 print(f"Kept {c.survivor.properties['name']}, merged {len(c.merged)} dups")203```204205`resolve_duplicates` picks the first-seen node as survivor, reassigns all incoming/outgoing edges to it, and deletes the rest. Self-loops from the reassignment are dropped.206207### Custom chunking + sequential linking208209Pattern from `examples/lexical_graph.py`:210211```python212prev = None213for chunk in chunks:214 node = Node(type="Chunk", properties={"text": chunk}, vector=embed(chunk))215 graph.insert_node(node)216 if prev is not None:217 graph.insert_edge(Edge(source_id=prev.id, target_id=node.id, relation="followed_by"))218 prev = node219```220221### Inspect before asking222223```python224result = graph.retrieve(query=q, query_vector=qv, max_hops=2, max_tokens=4000)225print(result.context_text) # See exactly what the LLM would receive226# Tune max_hops / max_tokens / search_limit before wiring ask()227```228229## Gotchas230231- **`vector_length` and `distance_metric` are locked at init.** Swapping embedding models means a new database. Valid metrics: `"l2"`, `"cosine"`, `"inner_product"`.232- **Missing vectors are silently zero-filled** in `insert_node` — `bulk_insert_nodes` skips nodes whose vectors don't match `vector_length` and logs a warning. Validate upstream if correctness matters.233- **HNSW is auto-built on init** (`auto_index=True`). Tune via `hnsw_ef_construction`, `hnsw_ef_search`, `hnsw_m`. Call `graph.compact_index()` after heavy deletes to reclaim space (also called automatically by `delete_node`).234- **FTS index is lazy** — first `search_nodes`/`hybrid_search` call after writes rebuilds it. Expect first-search latency. Force a rebuild with `graph.reindex()` if you want it warm before traffic.235- **Edge dedup key is `(source_id, target_id, relation)`.** Relations are normalized (lowercased, underscored) before comparison — `"Reports To"` and `"reports_to"` collide. Edge properties are NOT part of the key.236- **`delete_node` cascades edges in both directions** (as source AND as target). No orphan-edge safety net.237- **`merge_node` strategies** — `UPDATE` shallow-merges dicts (incoming wins on collision), `REPLACE` overwrites wholesale, `KEEP` only inserts if new. Pick intentionally.238- **`similarity_threshold=1.0` is exact match** (the default). Lower it to enable Jaro-Winkler fuzzy matching on string properties. Non-string properties always use JSON equality.239- **`match_type=True` (default) requires same `type` for merge.** Set `False` to merge across types — rarely what you want.240- **`resolve_duplicates` is O(n²)-ish** in fuzzy mode. For large graphs, narrow with `match_type` and a tight `vector_threshold` first.241- **`extraction` and `algorithms` are optional extras.** Wrap imports in try/except or check `pip show` before recommending code that depends on them.242- **Single-writer DuckDB.** Connection pooling and `@with_retry` (exponential backoff on transient IO errors) are built in, but don't open the same file from multiple processes for concurrent writes.243- **`cursor()` returns independent cursors** for concurrent reads; the main connection is RLock-guarded for writes.244- **`ask()` with `llm_callable=None`** returns retrieval only — no generation. Always use this first to validate context before paying for LLM calls.245246## Data models247248| Model | Key fields |249|---|---|250| `Node` | `id: UUID`, `type: str \| None`, `properties: dict`, `vector: list[float]` |251| `Edge` | `id`, `source_id`, `target_id`, `relation: str`, `weight: float \| None` |252| `SearchResult` | `node`, `score` (higher = better for both BM25 and hybrid) |253| `NearestNode` | `node`, `distance` (lower = closer) |254| `TraversalResult` | `node`, `depth`, `path: list[UUID]` |255| `RetrievalContext` | `node`, `relationships: list[dict]`, `hop_distance: int` |256| `RetrievalResult` | `query`, `contexts`, `context_text`, `token_estimate`, `seed_node_count`, `total_node_count` |257| `MergeResult` | `node`, `created: bool` (True = inserted, False = updated) |258| `EdgeMergeResult` | `edge`, `created: bool` |259| `DuplicateCluster` | `survivor: Node`, `merged: list[Node]` |260261All models are Pydantic. IDs auto-generate as UUIDs.262263## Examples in the repo264265- `examples/openai_example.py` — OpenAI embeddings, similarity search, attribute lookup266- `examples/lexical_graph.py` — chunked Wikipedia text with SentenceTransformer, sequential `followed_by` edges267- `examples/dspy_example_typed_pred.py` — DSPy typed-predictor extraction268- `examples/test_ingest.py` — parallel extraction (50 workers, 0.88 threshold) + post-pass `resolve_duplicates` at 0.90269270Read `examples/test_ingest.py` before building a real ingest pipeline — it's the template.271272## Testing273274```sh275python3 -m pytest tests/tests.py -v276```277278296 tests cover the public API. Run them when modifying the library.279280---281> Source: [bradAGI/GraphMemory](https://github.com/bradAGI/GraphMemory) — distributed by [TomeVault](https://tomevault.io).282<!-- tomevault:4.0:skill_md:2026-06-17 -->