Skip to content

Releases

v0.8.7 — MCP --tag scope fix

Choose a tag to compare

@jpmanson jpmanson released this 04 Nov 02:51

Fixes

  • mcp: --tag is no longer silently bypassed by the graph and passage tiers. The graph tools (graph_query, text_search, graph_neighbors) and the --document-index tier read the whole database, so combining them with --tag exposed concepts outside the tag scope. grafito-mcp now rejects --tag together with --enable-graph or --document-index, and build_tools raises ValueError for the same combination. Docs and --help state 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

Choose a tag to compare

@jpmanson jpmanson released this 19 Oct 03:08

Features

  • cypher: string predicates (STARTS WITH, ENDS WITH, CONTAINS), DETACH DELETE, and RETURN after a leading MERGE.
  • cypher: relationship type alternatives, e.g. [:A|B].

Fixes

  • cypher: a relationship is no longer matched twice within the same MATCH chain (relationship uniqueness).

v0.8.5 — Cypher CREATE bindings and property-map parameters

Choose a tag to compare

@jpmanson jpmanson released this 19 Oct 02:07

Fixes

  • cypher: variables bound in one CREATE pattern are now shared with the following patterns of the same clause, so CREATE (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

Choose a tag to compare

@jpmanson jpmanson released this 07 Aug 22:42

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

Choose a tag to compare

@jpmanson jpmanson released this 07 Aug 19:18

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:

  • width
  • value
  • color
  • title
  • dashes
  • arrows

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_NEAR edges
  • 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

Choose a tag to compare

@jpmanson jpmanson released this 07 Aug 10:27
844e137

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: benefit

hybrid_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 untouched

Which 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

Choose a tag to compare

@jpmanson jpmanson released this 07 Aug 01:14
5dd5de4

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 context

The 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 error

Arithmetic, 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 unsorted

The 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, alphabetical

Sorting 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

Choose a tag to compare

@jpmanson jpmanson released this 07 Aug 00:16
17f1a2d

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 Hastings

hops 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_paper

Anything 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_edges cut 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

Choose a tag to compare

@jpmanson jpmanson released this 06 Aug 14:26
4438d01

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=False no longer duplicates. It previously stacked a full second copy of the graph; it now adds only the edges a wider k discovers.
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 duplicate

Behaviour 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: DatabaseError

SIMILAR() 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

Choose a tag to compare

@jpmanson jpmanson released this 06 Aug 14:00
fd8bec1

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 meaningful

This 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) raising TypeError: unhashable type: 'Node'. Graph entities are mutable dataclasses; DISTINCT now keys them by identity, which also fixes collect(DISTINCT n) and UNION over nodes. This predates v0.7.0.

Full changelog: v0.7.0...v0.7.1