Graph Retrieval
Query a knowledge graph with KnowledgeGraphRetriever — seeded traversal, multi-hop beam search, and access control enforced on edges.
KnowledgeGraphRetriever (dynamiq.nodes.knowledge_graphs) is the graph sibling of VectorStoreRetriever: it turns a natural-language question into bounded, access-filtered facts an agent can consume directly. Build the graph first — see Knowledge Graphs.
It is also the controlled alternative to letting an LLM write Cypher. Filters and result bounds are compiled server-side into a single parameterized query, so an agent can narrow its own access but never widen it or inject Cypher.
How a retrieval runs
- Seed. Find the entry-point entities the question is about — by LLM entity extraction, by explicit names or ids, or by embedding similarity.
- Expand. Walk
max_hopshops out through visible edges. Beyond one hop this is a beam search: each hop keeps only the most relevant edges and expands from those endpoints, so the frontier stays bounded even around hub entities. - Render. Turn each surviving edge into a fact string and return it as a
Document.
from dynamiq.connections import Neo4j as Neo4jConnection
from dynamiq.connections import OpenAI as OpenAIConnection
from dynamiq.nodes.knowledge_graphs import KnowledgeGraphRetriever, Ontology
from dynamiq.nodes.llms import OpenAI
retriever = KnowledgeGraphRetriever(
connection=Neo4jConnection(),
llm=OpenAI(connection=OpenAIConnection(), model="gpt-4o-mini", temperature=0),
ontology=Ontology(
entity_types=["Person", "Organization", "System"],
relationship_types=["WORKS_AT", "USES"],
),
top_k=20,
)
result = retriever.run(input_data={"query": "What AI system does Acme Capital use?"})
print(result.output["content"])Pass the same ontology you ingested with, so the question is parsed for the entity kinds the graph actually contains.
Configuration
connectionNeo4j | ApacheAGE | AWSNeptunegraph_storeBaseGraphStorellmNodeontologyOntologyfiltersdicttop_kintmax_hopsintbeam_widthintdocument_rerankerNodetext_embedderTextEmbeddervector_top_kintseed_by_querybooldocument_retrieverAnysummarizebooldatabasestrgraph_namestrPer call the input schema accepts query (required) plus top_k, max_hops (1–4), entities, entity_ids, and filters.
Choosing how to seed
Seeding decides which entities the walk starts from. The modes are tried in this order:
| Mode | How to trigger | When to use |
|---|---|---|
| By id | entity_ids in the input | Exact and variant-proof. Used by hybrid retrieval and by an agent iterating from a previous fact's neighbor. |
| By vector | text_embedder set and the entity vector index exists | Matches "car" to an entity named "automobile". Seeds on each extracted name, or on the whole question when there are no names. |
| By name | entities in the input, else LLM-extracted names | The default. On Neo4j with the entity_name index this is a full-text seek; otherwise a portable CONTAINS scan. |
| By raw query | No llm and no names | Fallback — a recall-oriented fuzzy match over the whole question. |
Explicit entities skip LLM extraction entirely. seed_by_query=True also skips it and seeds the top entities by the whole-question embedding — simpler and context-preserving, at the cost of entity anchoring.
Names are matched fuzzily either way: tokens are AND-ed within a name and OR-ed across names, so Alice Smith matches the full name and its typos rather than any one-token overlap.
Index-backed seeding is Neo4j-only. On Apache AGE and Neptune — or on a Neo4j whose indexes do not exist yet — the retriever transparently falls back to the portable scan. It probes for the indexes once at init.
Multi-hop questions
At max_hops=1 you get the named entity's immediate facts. Chain questions need a second hop: "what does Jane's employer use?" resolves as Jane -WORKS_AT-> Acme on hop 1, then Acme -USES-> Helios on hop 2.
result = retriever.run(
input_data={"query": "What system does Jane Doe's employer use?", "max_hops": 2}
)Each hop keeps only beam_width edges and expands only from their endpoints, so a hub entity cannot explode the frontier. The seeds are excluded from the second frontier — hop 1 already expanded them — and every hop re-applies the same locked filters, so access control holds at every depth.
Reliable cross-hop ranking needs edge embeddings. Without a writer-side entity_embedder, hops are cut by position rather than relevance and a deep chain fact may lose to a seed-adjacent one.
Filters
Filters use the same structured grammar as the vector-store retrievers, so you write them identically for both families:
# Comparison
{"field": "source_url", "operator": "==", "value": "https://example.com/handbook"}
# Nested logical
{
"operator": "AND",
"conditions": [
{"field": "allowed_principals", "operator": "contains_any", "value": ["group:finance"]},
{"field": "year", "operator": ">=", "value": 2024},
],
}Supported operators: ==, !=, >, >=, <, <=, in, not in, and contains_any. Logical operators are AND and OR, nestable. Filters apply to edge properties — the flattened document metadata the extractor stamped at ingestion.
contains_any is list-to-list intersection with default-deny semantics: the edge survives only if its list property shares at least one element with your value list, and a null property is treated as empty and excluded.
Field names are validated as identifiers and values are always bound parameters, so a filter can never be an injection vector.
Access control
All access metadata lives on edges. A node is visible exactly when it is reachable through a visible edge, so one locked edge filter scopes the entire result — including every hop of a multi-hop walk.
# Chosen by the workflow author, never by the agent.
PRINCIPALS = ["group:finance"]
graph_tool = KnowledgeGraphRetriever(
name="graph-retriever",
connection=Neo4jConnection(),
llm=OpenAI(connection=OpenAIConnection(), model="gpt-4o-mini", temperature=0),
ontology=ONTOLOGY,
filters={"field": "allowed_principals", "operator": "contains_any", "value": PRINCIPALS},
top_k=20,
)The node-level filters are locked: they are not part of the tool's input schema, so an agent cannot see, drop, or widen them. A caller-supplied filters value is AND-ed on top and can only narrow further. This is the trust boundary — the LLM supplies the question, your code supplies who is asking.
With no locked filters, all edges are visible.
Refining results
Reranking. A high-degree entity can expand into many edges that all match the seed equally well. A cross-encoder reranker scores each rendered fact against the query so precision comes from relevance rather than position. Over-fetch by setting this node's top_k above the reranker's:
from dynamiq.connections import Cohere as CohereConnection
from dynamiq.nodes.rankers import CohereReranker
retriever = KnowledgeGraphRetriever(
connection=Neo4jConnection(),
llm=llm,
ontology=ONTOLOGY,
top_k=50,
document_reranker=CohereReranker(connection=CohereConnection(), top_k=10),
)Reranking runs before the top_k cap so a deep multi-hop fact is kept or dropped on relevance. A reranker failure degrades to the unranked facts rather than failing the read.
Grounding in source text. Point document_retriever at anything exposing get_documents_by_id — the vector-store retrievers all do — and the node fetches the verbatim chunks behind the retrieved facts. Each fact came from an edge the caller was already entitled to see, so no extra access check is needed.
Summarizing. With summarize=True the node's llm composes an answer from the retrieved context; the raw retrieval stays available under context. The validator rejects summarize=True without an llm.
Output
{
"content": "...", # the answer text an agent reads
"facts": "- Jane Doe -[WORKS_AT]-> Acme Capital\n- Acme Capital -[USES]-> Helios",
"documents": [...], # one Document per fact, with edge metadata
"source_documents": [...], # verbatim chunks, when document_retriever is set
}facts and source_documents are always present so consumers never have to infer the shape. content prefers verbatim source text when source documents were fetched, falls back to the fact list otherwise, and becomes the composed answer when summarize=True — in which case the pre-summary context moves to context.
Each fact Document renders as source -[relation]-> target, with the edge's description appended when the extractor captured one. Attribute edges render with the attribute key as the relation, so a value reads as Jane Doe -[title]-> Chief Investment Officer rather than a bare string. The edge's metadata — including source_doc_ids for grounding — rides on Document.metadata; the edge embedding is used server-side for ranking and is never surfaced.
GraphRAG: graph and vectors together
Facts and passages answer different questions, so give an agent both and let it choose. CypherExecutor rounds it out as a power tool for queries the retriever cannot express.
from dynamiq.connections import Neo4j as Neo4jConnection
from dynamiq.connections import OpenAI as OpenAIConnection
from dynamiq.connections import Qdrant as QdrantConnection
from dynamiq.nodes.agents import Agent
from dynamiq.nodes.embedders import OpenAITextEmbedder
from dynamiq.nodes.knowledge_graphs import KnowledgeGraphRetriever, Ontology
from dynamiq.nodes.llms import OpenAI
from dynamiq.nodes.retrievers import QdrantDocumentRetriever, VectorStoreRetriever
from dynamiq.nodes.tools import CypherExecutor
from dynamiq.nodes.types import InferenceMode
openai_connection = OpenAIConnection()
ontology = Ontology(
entity_types=["Person", "Organization", "System"],
relationship_types=["WORKS_AT", "USES"],
)
vector_tool = VectorStoreRetriever(
name="vector-search",
text_embedder=OpenAITextEmbedder(
connection=openai_connection, model="text-embedding-3-small"
),
document_retriever=QdrantDocumentRetriever(
connection=QdrantConnection(), index_name="kg_demo"
),
top_k=4,
)
graph_tool = KnowledgeGraphRetriever(
name="graph-retriever",
connection=Neo4jConnection(),
llm=OpenAI(connection=openai_connection, model="gpt-4o-mini", temperature=0),
ontology=ontology,
filters={
"field": "allowed_principals",
"operator": "contains_any",
"value": ["group:public"],
},
top_k=20,
)
cypher_tool = CypherExecutor(name="knowledge-graph", connection=Neo4jConnection())
agent = Agent(
name="graphrag-agent",
llm=OpenAI(connection=openai_connection, model="gpt-4o-mini", temperature=0.0, max_tokens=4000),
role=(
"Answer questions using three tools. Prefer graph-retriever for relationship "
"questions (how is X connected to Y, what does X use); use vector-search for "
"descriptive what-is questions; use knowledge-graph (Cypher) only when "
"graph-retriever is not enough. Cite which tool gave you each fact."
),
tools=[vector_tool, graph_tool, cypher_tool],
inference_mode=InferenceMode.XML,
max_loops=12,
)
result = agent.run(
input_data={"input": "Who is the CIO of Acme Capital, and what AI system does the firm use?"}
)
print(result.output["content"])Hybrid retrieval
Seeding by entity_ids is what makes the two halves compose. Resolve entities however you like — a vector search over entity names, a previous fact's neighbor — then hand the ids to the retriever to anchor traversal exactly, with no name-matching ambiguity:
facts = retriever.run(
input_data={
"query": "What does this organization use?",
"entity_ids": ["9a5f...c31"],
"max_hops": 2,
}
)An agent can also walk the graph iteratively, feeding a fact's neighbor back as the next call's entities seed.
Choosing a retrieval tool
| Tool | Returns | Access control | Best for |
|---|---|---|---|
KnowledgeGraphRetriever | Facts (edges) as Documents | Locked edge filters an agent cannot widen | Relationship and multi-hop questions with a hard access boundary |
VectorStoreRetriever | Text chunks | Store-level metadata filters | Descriptive questions over unstructured prose |
CypherExecutor | Raw query rows | None — the query is whatever the caller writes | Aggregate, schema, and custom queries; trusted callers only |
Next steps
Knowledge Graphs
Extract entities and relationships from documents with an LLM, resolve them to durable identities, and upsert them into a graph store.
LLM Providers
One unified LLM node across 27 providers — provider table, connection env vars, common parameters, vision and PDF support, and fallbacks.