Skip to content

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:

sub = db.semantic_subgraph("autonomous agents", k=20, expand=2)

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:

sub = db.text_subgraph("attention mechanism", k=20, expand=1)

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:

sub = db.hybrid_subgraph("ENOSPC retry policy", k=20, expand=1)

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}, ...]:

hits = db.hybrid_search("ENOSPC retry policy", k=10, vector_weight=1.0, text_weight=1.5)

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))
Roman Empire → Saxons → Vikings → Battle of Hastings

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 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
@dataclass
class Subgraph:
    """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.
    """

    nodes: list[Node] = field(default_factory=list)
    relationships: list[Relationship] = field(default_factory=list)
    #: Seed hits, best first: ``[{"node": Node, "score": float}, ...]``.
    seeds: list[dict[str, Any]] = field(default_factory=list)
    #: Seed node id -> retrieval score. Expanded nodes are absent.
    scores: dict[int, float] = field(default_factory=dict)
    #: Node id -> hop distance from the nearest seed (0 for seeds).
    hops: dict[int, int] = field(default_factory=dict)
    #: Ordered node ids of each route found, when the subgraph came from
    #: :meth:`~grafito.GrafitoDatabase.path_context`. Empty otherwise.
    paths: list[list[int]] = field(default_factory=list)

    def node_ids(self) -> list[int]:
        """Ids of every node in the subgraph, in insertion order."""
        return [node.id for node in self.nodes]

    def seed_ids(self) -> list[int]:
        """Ids of the seed nodes, best-scoring first."""
        return [hit["node"].id for hit in self.seeds]

    def is_empty(self) -> bool:
        return not self.nodes

    def __len__(self) -> int:
        return len(self.nodes)

    def to_networkx(self, directed: bool = 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`.
        """
        try:
            import networkx as nx
        except ImportError as exc:  # pragma: no cover - networkx is a core dependency
            from .exceptions import DatabaseError

            raise DatabaseError(
                "networkx is not installed. Install with `pip install networkx`."
            ) from exc

        graph = nx.MultiDiGraph() if directed else nx.MultiGraph()
        for node in self.nodes:
            graph.add_node(
                node.id,
                labels=list(node.labels),
                properties=dict(node.properties),
                uri=getattr(node, "uri", None),
                score=self.scores.get(node.id),
                hops=self.hops.get(node.id),
            )
        for rel in self.relationships:
            graph.add_edge(
                rel.source_id,
                rel.target_id,
                key=rel.id,
                id=rel.id,
                type=rel.type,
                properties=dict(rel.properties),
                uri=getattr(rel, "uri", None),
            )
        return graph

node_ids()

Ids of every node in the subgraph, in insertion order.

Source code in grafito/subgraph.py
def node_ids(self) -> list[int]:
    """Ids of every node in the subgraph, in insertion order."""
    return [node.id for node in self.nodes]

seed_ids()

Ids of the seed nodes, best-scoring first.

Source code in grafito/subgraph.py
def seed_ids(self) -> list[int]:
    """Ids of the seed nodes, best-scoring first."""
    return [hit["node"].id for hit in self.seeds]

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.

Source code in grafito/subgraph.py
def to_networkx(self, directed: bool = 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`.
    """
    try:
        import networkx as nx
    except ImportError as exc:  # pragma: no cover - networkx is a core dependency
        from .exceptions import DatabaseError

        raise DatabaseError(
            "networkx is not installed. Install with `pip install networkx`."
        ) from exc

    graph = nx.MultiDiGraph() if directed else nx.MultiGraph()
    for node in self.nodes:
        graph.add_node(
            node.id,
            labels=list(node.labels),
            properties=dict(node.properties),
            uri=getattr(node, "uri", None),
            score=self.scores.get(node.id),
            hops=self.hops.get(node.id),
        )
    for rel in self.relationships:
        graph.add_edge(
            rel.source_id,
            rel.target_id,
            key=rel.id,
            id=rel.id,
            type=rel.type,
            properties=dict(rel.properties),
            uri=getattr(rel, "uri", None),
        )
    return graph