Repository navigation
Releases
v0.8.7 — MCP --tag scope fix
Fixes
- mcp:
--tagis no longer silently bypassed by the graph and passage tiers. The graph tools (graph_query,text_search,graph_neighbors) and the--document-indextier read the whole database, so combining them with--tagexposed concepts outside the tag scope.grafito-mcpnow rejects--tagtogether with--enable-graphor--document-index, andbuild_toolsraisesValueErrorfor the same combination. Docs and--helpstate that those tiers are not tag-scoped. (#18) - ci: publish workflow now supports metadata 2.5 and manual runs.
v0.8.6 — Cypher string predicates, relationship alternatives and uniqueness
Features
- cypher: string predicates (
STARTS WITH,ENDS WITH,CONTAINS),DETACH DELETE, andRETURNafter a leadingMERGE. - cypher: relationship type alternatives, e.g.
[:A|B].
Fixes
- cypher: a relationship is no longer matched twice within the same
MATCHchain (relationship uniqueness).
v0.8.5 — Cypher CREATE bindings and property-map parameters
Fixes
- cypher: variables bound in one
CREATEpattern are now shared with the following patterns of the same clause, soCREATE (a:A), (b:B), (a)-[:R]->(b)links the nodes it just created instead of making new ones. - cypher: property maps can now be passed as parameters (
CREATE (n:Label $props)). - examples: semantic neighborhood subgraph no longer includes an isolated Document node.
v0.8.4 — Chunker whitespace fix and retuned PDF eval
Patch release with a RecursiveChunker correctness fix and a retuned PDF GraphRAG evaluation.
Chunker
Recursive splitting could emit a piece consisting only of a separator — for example a lone "\n" when pending splits were flushed ahead of an oversized one — which then became a ChunkSpec carrying no content. Those pieces are now dropped.
Skipped pieces still advance the internal search floor. Without that, the following piece could re-match an earlier occurrence of its own text and leave real characters uncovered by any chunk.
Contract change: splitting a whitespace-only text now returns no specs at all, where it previously returned one. The module docstring documents this.
PDF GraphRAG evaluation
The notebook and evaluation script previously raised max_chars to 1400 while dropping chunk overlap, to stop graph node labels from looking like duplicated mid-sentence fragments. The larger budget cost retrieval quality: section_hit_rate fell from 1.00 to 0.83 for the reading-expansion strategies, avg_ce_max dropped across all ten strategies, and context grew 39%.
Measuring the two knobs separately shows max_chars was the culprit, not the overlap — 1100/0 and 1100/120 score alike, while 1400/0 and 1400/120 both fail. Larger chunks dilute the evidence, which lowers the cross-encoder score and moves the top-1 vector hit into the wrong section.
Both now use 1100 characters with no overlap: labels stay readable, section_hit_rate returns to 1.00, avg_ce_max to 4.48 (from 4.54), and context is slightly cheaper than before the change. Only avg_term_recall gives up about a point, which is what the overlap was buying.
Evaluation outputs were regenerated and the notebook re-executed against this configuration.
Full Changelog: v0.8.3...v0.8.4
v0.8.3 — Weighted GraphRAG visualizations
Patch release focused on GraphRAG visualization and evaluation.
Why this release
The GraphRAG work added semantic proximity edges (SEMANTIC_NEAR) that are most useful when they can be inspected visually. The missing piece was that PyVis exports did not forward edge styling attributes from NetworkX, so weighted semantic relationships could not be rendered with variable thickness or richer tooltips.
Visualization
PyVis exports now preserve common edge styling attributes from NetworkX:
widthvaluecolortitledashesarrows
This enables weighted semantic graph visualizations, including SEMANTIC_NEAR edges whose thickness is derived from cosine similarity.
Example use case:
for _, _, _, edge in graph.edges(keys=True, data=True):
score = edge["properties"]["score"]
edge["type"] = f"SEMANTIC_NEAR\ncos={score:.2f}"
edge["width"] = 2.0 + score * 7.0
edge["title"] = f"cosine similarity: {score:.3f}"GraphRAG examples
The PDF chunking Colab notebook now demonstrates:
- materializing semantic proximity as
SEMANTIC_NEARedges - showing cosine similarity on semantic relationships
- rendering semantic edge thickness based on similarity
- comparing reading-order expansion with semantic-neighborhood expansion
Evaluation
Added a reproducible PDF GraphRAG retrieval evaluation over Anthropic's Building Effective AI Agents PDF. It compares:
- vector top-k retrieval
- reading-order expansion
- semantic proximity expansion
- hybrid retrieval
- reranking
The evaluation is intentionally defensive: it shows that semantic graph expansion can improve evidence coverage, but also that it can add noise without reranking or context budgeting.
Compatibility
Patch release. No breaking API changes.
Existing PyVis exports keep working. If callers add supported edge attributes to the NetworkX graph before rendering, those attributes are now preserved in the generated HTML.
Validation
- Targeted tests:
71 passed, 1 skipped - Full test suite before release bump:
1976 passed, 3 skipped - Strict docs build passes
Full Changelog: v0.8.2...v0.8.3
v0.8.2 — Natural-language queries in hybrid retrieval
A user-typed question was a syntax error rather than a search. SQLite FTS5 parses its MATCH argument as a query language, so the punctuation people actually type went to the parser instead of the index:
db.hybrid_search("what does ENOSPC mean?") # sqlite3.OperationalError: fts5: syntax error near "?"
db.text_subgraph("graph databases?") # sqlite3.OperationalError
db.text_search("cost-benefit analysis") # sqlite3.OperationalError: no such column: benefithybrid_search caught DatabaseError, but text_search leaked the raw sqlite3.OperationalError, so the exception escaped the fusion logic entirely.
Wrapping the error was only half of it
Catching the error still discarded the whole lexical half, quietly turning hybrid retrieval into vector-only search — precisely for the identifiers, surnames and error codes that are the reason to fuse two retrievers in the first place. A question mark should not cost you the BM25 side.
Unparseable queries are now retried with each token quoted as an FTS5 string literal (graph databases? → "graph" "databases"), which is the same AND-of-terms FTS5 applies to a bare word list, minus the parse failure.
db.hybrid_search("what does ENOSPC mean?") # lexical half preserved
db.hybrid_search("AT&T") # works
db.hybrid_search("graph OR nothing") # valid FTS5 operators still pass through untouchedWhich methods tolerate what
| Method | Punctuation | Why |
|---|---|---|
text_search |
raises | Its documented contract is FTS5 syntax (AND/OR/NEAR/10/term*). Silently rewriting those operators would break queries that rely on them. |
text_subgraph |
tolerates | Retrieval, not query syntax. Propagates if the retry also fails — with no second half to fall back on, an empty subgraph would report "no matches" for a broken search. |
hybrid_search / hybrid_subgraph |
tolerates | Falls back to the vector side if the retry also fails. |
SQLiteFTSIndex.search got the same treatment, so custom text-index backends stop leaking raw sqlite3 errors through text_search_custom.
Compatibility
Patch release, no new API. The one behaviour change to code that already worked: text_search now raises DatabaseError instead of sqlite3.OperationalError — the type the rest of the library already raised. Catch DatabaseError if you were catching the sqlite3 error directly.
Full Changelog: v0.8.1...v0.8.2
v0.8.1 — Cypher WITH fixes
Four pre-existing defects in WITH, all found while documenting a limitation for the 0.8.0 pattern-predicate work. Each fix let queries be written that had not parsed before, which is how the next one surfaced.
A WITH can filter on the aliases it projects
MATCH (n:P) WITH n, n.age AS age WHERE age > 25 RETURN n.name
-- was: Variable 'age' not found in contextThe non-aggregating path filtered the input rows, so the clause's own aliases did not exist yet — while the aggregating path already filtered after grouping, which is why WITH n, count(m) AS c WHERE c > 1 worked and the simple case did not.
The predicate now sees the projected names plus the bindings that came in. Keeping the latter is deliberate: standard Cypher drops them from scope, but filtering on an unprojected variable already worked here and tightening it would break queries for no gain.
WITH accepts the same expressions RETURN does
RETURN n.age * 2 AS doubled -- fine
WITH n.age * 2 AS doubled -- was a syntax errorArithmetic, comparisons and function calls had to be wrapped in parentheses to survive a WITH.
WITH * and RETURN *
MATCH (p)-[r:KNOWS]->(other)
WITH *
WHERE r.weight > 5
RETURN *Neither clause accepted *, so carrying a multi-variable match through a WITH meant naming every variable. It combines with named items — WITH *, p.age * 2 AS doubled.
Two semantics worth knowing: * expands to bound variables, not to columns an earlier stage produced (otherwise it would accumulate at every stage); and with an aggregate it contributes every variable to the implicit GROUP BY, so WITH *, count(m) groups per (n, m) rather than per n.
ORDER BY / SKIP / LIMIT after a trailing RETURN
MATCH (n:P) WITH n.age AS d RETURN d AS r ORDER BY r -- came back unsortedThe WITH parser kept one slot per modifier and accepted them in any order, so anything written after the RETURN was stored as the WITH's own and applied before the final projection. With one on each clause, neither took effect.
They now attach to whichever clause they follow:
MATCH (p:Person)
WITH p, p.age AS age
ORDER BY age DESC LIMIT 10 -- top ten by age
RETURN p.name AS name
ORDER BY name -- those ten, alphabeticalSorting keys resolve against the RETURN's output and the rows that fed it, since ORDER BY may name either a column it introduced or a name the WITH carried without projecting.
Scope
Patch rather than minor: no new API surface, and nothing that already worked behaves differently. Each of these was a syntax error or a silently ignored clause before.
Full changelog: v0.8.0...v0.8.1
v0.8.0 — Hybrid retrieval, path context and graph analysis
Six features built on top of the 0.7 line, plus the test harness that found two of the bugs fixed along the way.
Hybrid retrieval
RRF fusion lived only inside DocumentIngestor. It is now available on any graph:
db.hybrid_search("ENOSPC retry policy", k=10)
db.hybrid_subgraph("ENOSPC retry policy", k=20, expand=1)The two modes fail differently, which is the reason to fuse them — vector search misses exact tokens it never learned (identifiers, error codes), lexical search misses paraphrase. RRF merges by rank, so cosine similarity and BM25 never have to be made comparable. Degrades rather than fails: with only one index configured, that side is returned alone.
Path context
When the answer is what connects two concepts, not what is nearest either of them:
sub = db.path_context(["Roman Empire", "Battle of Hastings"], max_hops=3,
exclude_rel_types=["SEMANTIC_SIMILAR"])
# Roman Empire → Saxons → Vikings → Battle of Hastingshops counts position along the route, not distance from a hit — an intermediate document is the answer here. An empty result means the concepts are not connected within max_hops, which is a real answer and one a top-k search would have hidden behind plausible-looking hits.
Patterns as boolean expressions
Previously a syntax error in every form, despite the docs showing it:
MATCH (p:Paper) WHERE NOT (p)-[:CITES]->() RETURN p.title
MATCH (p:Paper) RETURN p.title, (p)-[:CITES]->(:Paper) AS cites_a_paperAnything MATCH accepts works inside: types, labels, direction, inline properties, multi-hop and variable-length paths. Matching stops at the first hit, so asking whether a node has a neighbour costs the same whether it has one or a thousand.
Mutual k-NN
create_semantic_graph(symmetrize="union" | "mutual" | "directed"). Reciprocity removes the edges that damage clustering most — a node at the edge of a cluster gets pulled into distant nodes' neighbour lists without them appearing in its own.
It is not a free improvement, and the docs carry the measurements. On overlapping clusters, mutual at k=3 cut cross-cluster edges from 40% to 14% and shattered three planted clusters into eight fragments; at k=5 the fragmentation was gone and so was most of the advantage. It trades coverage for precision and needs a larger k than union.
Community labelling
for community in db.communities("louvain", label_terms=3):
print(f"[{community.label}] — {community.size} documents")[graph, nodes, databases] — 3 documents
[search, embeddings, nearest] — 3 documents
[pasta, carbonara, cooking] — 3 documents
tfidf by default, which discounts words common to every community without a stopword list. This is the cheap end of topic modelling: clusters come from the graph, and the words only describe what landed in each — labelling never improves a partition, it reports one.
An invariant harness
Every bug fixed in 0.7.1 and 0.7.2 was a violated invariant found by reading code, not by a failing test. tests/invariants/ now states those properties and checks them across a matrix of parameters on generated corpora with planted clusters.
It found two defects on its first run:
max_edgescut builds mid-node. A node stopped half way through its neighbours still counted as processed, so no later refresh would finish it, and repeated capped refreshes converged to a graph missing ~11% of its edges.- The refresh loop does not terminate on
nodes_processed— which is what the docs suggested until I ran it.
The harness is validated by mutation: reverting each 0.7.x fix fails 4, 12, 12 and 2 properties respectively.
Behaviour change
max_edges is no longer a hard ceiling. It is enforced between nodes and can overshoot by up to k-1. Cutting a node off mid-search left it looking processed with an incomplete neighbourhood that no later refresh would finish; every node a capped build processes is now complete, so repeated capped refreshes reach the whole graph.
Everything else is additive. undirected= still works as shorthand for symmetrize, community labelling is off by default, and no existing signature changed.
Still missing
Retrieval quality remains unmeasured. The invariant harness covers structural properties — atomic rebuilds, converging refreshes, no stranded nodes — but expand depth, reranker choice, min_score, RRF weights and symmetrize all change quality in ways nothing here measures. Build a small labelled set for your corpus before tuning.
Full changelog: v0.7.2...v0.8.0
v0.7.2 — Refresh no longer strands nodes
Bug-fix release. If you use refresh_semantic_graph() with max_edges, upgrade — the incremental refresh could exclude nodes from the semantic graph permanently.
refresh could exclude nodes permanently
approximate treated a node as already linked if it appeared at either end of a generated edge. But a node that merely received an edge was never searched — it has no neighbours of its own — and a build cut short by max_edges leaves exactly those behind. Every later refresh skipped them:
truncated build: 3 edges, 2 nodes processed
sources: [d0, d1] receivers only: [d2]
later refresh: 3 skipped → d2 neighbourless, permanently
This is the same "excluded forever" failure v0.7.1 fixed for hand-made edges, reached by a different route — and max_edges is exactly the parameter you use on the large corpora where incremental refresh matters most.
A node now counts as processed only if it is the source of a generated edge: the node whose neighbourhood was actually searched.
Refresh converges instead of accumulating
Being strict re-searches nodes whose edges were all deduplicated away, so builds now deduplicate against edges already stored. Two consequences:
refresh_semantic_graph()is idempotent. Running it repeatedly is a no-op instead of adding copies.replace=Falseno longer duplicates. It previously stacked a full second copy of the graph; it now adds only the edges a widerkdiscovers.
db.create_semantic_graph(k=1, min_score=0.0)
db.create_semantic_graph(k=3, min_score=0.0, replace=False) # adds, does not duplicateBehaviour change, and the old behaviour was a bug — appending an identical copy of every edge is not something a caller wants.
min_score rejects booleans
bool subclasses int, so min_score=True silently became a threshold of 1.0 and built nothing:
db.create_semantic_graph(k=2, min_score=True) # was: 0 edges. now: DatabaseErrorSIMILAR() already refused non-numbers; the two APIs agree again.
A failed ingest no longer leaves an orphan FTS index
index_documents(configure_fts=True) registered the index for documents that then never loaded. It is now removed if the ingest raises — unless the index already existed, which is not this call's to undo.
Docs correction
v0.7.1 claimed "FTS5 indexes existing rows when created, so order does not matter". That is wrong. create_text_index registers the configuration and indexes rows as they are written, so registering it after a load leaves those documents unsearchable until rebuild_text_index():
db.create_text_index("node", "Paper", ["text"]) # before loading
db.index_documents(rows, label="Paper")
# or, if already loaded
db.create_text_index("node", "Paper", ["text"])
db.rebuild_text_index()index_documents is also now documented as not atomic: rows are written as they are read, because the alternative is holding a write transaction open across every embedding call. To resume after a failure, re-run — upsert=True makes replaying the whole batch safe.
Full changelog: v0.7.1...v0.7.2
v0.7.1 — Atomic semantic graph rebuilds and correct scoping
Bug-fix release for create_semantic_graph, introduced in v0.7.0. Two of these lose data — upgrade if you use the semantic graph.
Rebuilds are now atomic
create_semantic_graph(replace=True) deleted the old edges and committed, then ran the neighbour search — minutes on a large corpus — and only then inserted. A failure anywhere in between destroyed the semantic graph, and concurrent readers saw it empty for the whole build, not just on failure.
Neighbours are now computed first, and the old graph is swapped for the new one inside a single short transaction. A failed build leaves the previous graph intact. Constraint validation moved ahead of the delete as well.
approximate no longer skips nodes it should process
It treated any relationship of the type as evidence a node was already linked. A single hand-made SEMANTIC_SIMILAR edge therefore excluded both of its endpoints from every future refresh_semantic_graph() — permanently — as did edges generated from a different vector index.
It now counts only edges this method generated for the same index, which is what the generated_by marker was introduced for.
replace and drop are scoped by index
Rebuilding the semantic graph for one vector index deleted edges generated from another. Both now scope by index, and drop_semantic_graph(index=...) can target one:
db.drop_semantic_graph(index="papers_vec")min_score has a sane default
0.0 was wrong in both directions: on cosine it admitted orthogonal neighbours, and on l2 — where scores are negated distances (<= 0) — it admitted almost nothing.
It now defaults to 0.1, and refuses to guess on an l2 index rather than silently building an empty graph:
db.create_semantic_graph(k=15) # DatabaseError on l2
db.create_semantic_graph(k=15, min_score=-0.5) # explicit and meaningfulThis mirrors the rule SIMILAR() already followed; the inconsistency between the two APIs was the underlying bug. Passing min_score=0.0 explicitly still means 0.0.
Behaviour change: builds that relied on the implicit 0.0 will now produce fewer edges. Pass min_score=0.0 to keep the old result.
Also
index_documents(configure_fts=True)registers the full-text index in the same call, so hybrid retrieval works without a second one. Off by default.- Fix
count(DISTINCT n)raisingTypeError: unhashable type: 'Node'. Graph entities are mutable dataclasses; DISTINCT now keys them by identity, which also fixescollect(DISTINCT n)andUNIONover nodes. This predates v0.7.0.
Full changelog: v0.7.0...v0.7.1