Search Results as Subgraphs
semantic_search() returns a ranked list. semantic_subgraph() returns the same
hits plus how they connect — the thing a graph database knows that a vector
store does not.
sub = db.semantic_subgraph("autonomous agents", k=30, expand=1)
print(len(sub)) # nodes in the subgraph
print(sub.relationships) # every edge among them
print(sub.seeds) # the ranked hits that seeded it
Why a Subgraph
A top-k list treats results as independent, which they usually are not. Given the same 30 hits, the subgraph tells you that eight of them cite one another and form a cluster, that two are isolated, and which one sits in the middle. That is directly useful for three things: visualising a result, ranking within a result, and packing an LLM prompt with context that has structure rather than just relevance.
Expansion and Provenance
expand pulls in the neighbourhood of each hit:
Every node records where it came from:
sub.scores # {node_id: retrieval_score} — seeds only
sub.hops # {node_id: distance} — 0 for seeds, 1+ for expanded
This is what keeps the result explainable. Without it, a subgraph is an undifferentiated blob and there is no way to distinguish a strong direct match from something two hops away that happened to be adjacent.
All relationships between selected nodes are returned, not only the ones traversed — two seeds that link to each other show that link even if neither was reached from the other.
Controlling Expansion
sub = db.semantic_subgraph(
"autonomous agents",
k=20,
expand=2,
direction="out", # "both" (default), "out", "in"
rel_types=["CITES"], # traverse only these
exclude_rel_types=["SEMANTIC_SIMILAR"], # never traverse these
labels=["Article"], # only expand into these labels
max_nodes=500, # stop once this large
)
Expansion is exponential in dense graphs
One hop from a hub node can pull in thousands of nodes; expand=2 through
that hub reaches most of the database. max_nodes is the guard — expansion
stops once the subgraph reaches that size, which shows up as missing hops
rather than a hang.
exclude_rel_types is the sharper tool: derived edges like similarity links
connect everything to everything, so a single hop through them is already a
near-full scan.
Retrieval filters and expansion filters are separate. filter_labels and
filter_props constrain which hits seed the subgraph; labels and
rel_types constrain where expansion goes:
sub = db.semantic_subgraph(
"autonomous agents",
filter_props={"published": True}, # seed only from published articles
labels=["Article"], # but expand only into articles too
expand=1,
)
Lexical, Hybrid and Explicit Seeds
text_subgraph() is the FTS5/BM25 counterpart, taking the same expansion
arguments:
Unlike text_search(), it accepts
user-typed questions: a query FTS5 cannot parse is retried as literal terms
instead of raising.
hybrid_subgraph() seeds from both at once, fused with Reciprocal Rank Fusion:
This is usually the one you want. The two retrieval modes fail differently — vector search misses exact tokens it never learned (identifiers, surnames, error codes), lexical search misses paraphrase — and RRF merges them by rank, so cosine similarity and BM25 never have to be made comparable. It degrades rather than fails: with only one index configured, that side is returned alone.
hybrid_search() is the same fusion without the graph, returning
[{"node": Node, "score": float}, ...]:
No relevance floor
Like the top-k searches it builds on, hybrid_search returns k results
whenever the corpus has them, however weak the match — a query sharing
nothing with any document still comes back full, at scores near zero.
Filter on the score when an empty answer is the right answer.
subgraph() takes seeds directly — node ids, Node objects, or search hits —
so any retrieval strategy can feed it, including a hybrid fusion of your own:
sub = db.subgraph([node_id_1, node_id_2], expand=2)
sub = db.subgraph(my_fused_hits, expand=1) # [{"node": Node, "score": ...}]
Context From Routes, Not Rankings
path_context() asks a different question: not "what is nearest this concept?"
but "how do these concepts connect, and what lies between them?"
sub = db.path_context(
["Roman Empire", "Vikings", "Battle of Hastings"],
k=3,
max_hops=3,
exclude_rel_types=["SEMANTIC_SIMILAR"],
)
for route in sub.paths:
print(" → ".join(db.get_node(n).properties["title"] for n in route))
Each waypoint is located semantically, then routes are traced between
consecutive waypoints. The result is the union of those routes, so hops is
position along the route rather than distance from a hit — an intermediate
document is the answer here, not something incidental that turned up nearby.
A vector search for "Roman Empire Vikings Hastings" returns neither end well and nothing in between. This returns the material that connects them.
| Option | Meaning | Default |
|---|---|---|
k |
Candidates per waypoint. Cost grows with k² |
3 |
max_hops |
Longest route between two waypoints | 3 |
direction |
"both", "out", "in" |
"both" |
rel_types / exclude_rel_types |
Which edges may be travelled | all |
max_paths |
Stop after this many routes | 20 |
expand |
Neighbourhood to add around the routes | 0 |
Exclude similarity edges unless they are the subject
A materialised semantic graph connects everything to everything, so a route
through one means almost nothing. In the test corpus, "Roman Empire" and
"Italian pasta" are two hops apart through a single SEMANTIC_SIMILAR edge;
with that type excluded, there is no route at all — which is the truthful
answer.
An empty result means no pair of waypoints is connected within max_hops. That
is a real answer, and one a similarity search would have concealed behind
plausible-looking hits. Raise max_hops if you want a looser notion of
connected — but past three or four, "connected" stops meaning much.
Visualising
to_networkx() carries the provenance into the graph, so a viewer can size or
colour nodes by score and hop distance:
from grafito.integrations.viz import export_graph
sub = db.semantic_subgraph("autonomous agents", k=30, expand=1)
export_graph(sub.to_networkx(), "context.html", backend="cytoscape")
Node attributes: labels, properties, uri, score, hops.
Edge attributes: type, properties, uri.
Ranking Within a Result
Both graph algorithms accept graph=, so a subgraph can
be ranked on its own terms:
sub = db.semantic_subgraph("autonomous agents", k=50, expand=1)
graph = sub.to_networkx()
central = db.centrality("pagerank", graph=graph, limit=10)
clusters = db.communities("louvain", graph=graph, seed=42)
This answers "what is important among the things that matched?" — a different and often more useful question than global centrality, and the basis for graph-aware reranking: a node with a middling vector score that everything else in the result set points at is usually worth surfacing.
API Reference
grafito.subgraph.Subgraph
dataclass
Nodes and relationships selected by a search, with their provenance.
scores and hops are what make this explainable: every node either
matched the query directly (hops == 0, with a score) or was reached by
expansion from one that did (hops >= 1, no score). Without them a
subgraph is an undifferentiated blob and there is no way to tell a strong
match from something two hops away.
Source code in grafito/subgraph.py
node_ids()
seed_ids()
to_networkx(directed=True)
Build a NetworkX graph of this subgraph.
Nodes carry labels, properties, uri, plus the score and
hops provenance; edges carry type, properties and uri.
Suitable for :func:grafito.integrations.viz.export_graph and for the
graph= argument of :meth:~grafito.GrafitoDatabase.centrality.