Skip to content

Graph skeleton

The graph skeleton is a compact structural summary of the knowledge graph, generated once at server startup and injected into the agent system prompt. It gives the LLM a high-level map of the graph — which node types connect to which, via which relationships, and at what scale — before any tool calls are made.


Motivation

The LLM needs to know the graph structure to write useful Cypher queries. Two naive approaches have problems:

  • Full schema dump at startup — returns every edge type, every label combination, and every property. Too large for a system prompt; exhausts context for dense graphs.
  • Schema tools only — the LLM must call get_node_labels, get_edge_types, and so on before every query, adding multiple round-trips and increasing latency.

The skeleton is a middle ground: it surfaces the most structurally significant relationships at startup, leaving fine-grained exploration to schema tools when needed.


Algorithm

The skeleton is built by agentlib.graph.skeleton.build_skeleton() in three phases.

Phase 1 — discover label pairs per edge type

For each edge type, fetch all source and target labels:

MATCH (n)-[e:EDGE_TYPE]->(m)
RETURN labels(n), labels(m)

The result is deduplicated in pandas using drop_duplicates() on the (labels(n), labels(m)) columns. This gives all distinct (src_label, edge_type, tgt_label) triplets.

Why drop_duplicates() instead of LIMIT 1 per pair? TuringDB returns rows in storage order. A LIMIT 1 would see only the first label combination present for that edge type — missing all others. drop_duplicates() after a full fetch captures the complete polymorphic range.

Phase 2 — count each triplet exactly

For each unique (src_label, edge_type, tgt_label) triplet discovered in Phase 1, issue one count query:

MATCH (n:{src})-[e:{edge_type}]->(m:{tgt}) RETURN count(e)

This gives the exact edge count for the triplet.

Phase 3 — filter and rank

Using pandas:

  1. Drop triplets with count < min_count — filters out rare or coincidental connections.
  2. Sort descending by count.
  3. groupby("src").head(top_n) — keep only the top top_n connections per source node type.

This prevents high-degree node types from dominating the skeleton at the expense of rarer but still significant types.


Output format

Pathway (n=2532)
  -[hasEvent]-> Reaction  (18420 edges)
  -[hasEvent]-> Pathway   (4301 edges)
  -[inferredTo]-> Pathway (1203 edges)

Reaction (n=14071)
  -[input]-> PhysicalEntity       (52130 edges)
  -[output]-> PhysicalEntity      (43210 edges)
  -[catalystActivity]-> CatalystActivity  (19830 edges)
  -[hasModifiedResidue]-> TranslationalModification (8920 edges)
  -[regulatedBy]-> Regulation     (6710 edges)

...

Properties:
  dbId: Integer   displayName: String   text: String   ...

[Skeleton: top 5 connections per node type with ≥100 edges.
Use get_node_label_counts, get_edge_type_counts, get_property_types tools
for full exploration.]

The footer reminds the LLM that the skeleton is a summary and that schema tools are available for deeper exploration.


Configuration

Skeleton settings live in server/config.py under the TURINGDB_ env prefix.

Setting Env var Default Description
skeleton_min_count TURINGDB_SKELETON_MIN_COUNT 100 Minimum edge count for a triplet to appear. Raise for dense graphs to keep the skeleton compact; lower for sparse graphs where rare connections matter.
skeleton_top_n TURINGDB_SKELETON_TOP_N 5 Max connections shown per source node type, ranked by edge count. Prevents high-degree types from dominating the skeleton.

Server integration

build_skeleton() is synchronous (TuringDB queries are blocking). The server calls it at startup inside asyncio.to_thread() to avoid blocking the FastAPI event loop:

skeleton = await asyncio.to_thread(
    load_or_build_context,
    settings.data_dir,
    settings.graph,
    settings.url,
    settings.port,
    settings.skeleton_min_count,
    settings.skeleton_top_n,
)
app.state.skeleton = skeleton

load_or_build_context first checks for a cached file ({graph_name}_ctxt.md in data_dir). If the file exists it is read immediately; otherwise build_skeleton() runs, the result is written to disk, and returned. If generation fails (e.g. TuringDB is unreachable at startup), the server starts normally with an empty skeleton and the agent falls back to schema tools only.

The skeleton is stored on app.state and passed to the background worker at startup. Workers pass it to run_agent() on every job they process:

result = await run_agent(job.message, session, skeleton, emit_fn=agent_emit)

Inside run_agent, it is appended to the system prompt when non-empty:

+ (("\n\nGraph structure overview:\n" + skeleton) if skeleton else "")