Retrieve and answer
This guide shows how to use SearchEngine to retrieve entities and chunks from a knowledge graph. It also shows how to use build_agent to get cited answers.
Prerequisites
Install the storage, embedding, and extraction extras. The agent layer needs the agents extra:
pip install 'agentic-graphrag[neo4j,extract]'
# For the agent layer:
pip install 'agentic-graphrag[agents]'
Each code block on this page is complete. It opens a graph with the GENERIC schema, adds two sentences, and closes the graph at the end. The agent examples read LLM_BASE_URL, LLM_MODEL_ID, and LLM_API_KEY from the environment or from .env.
Direct SearchEngine Usage
SearchEngine searches entity labels that exist in your graph. The example below ingests its data with the GENERIC schema, so GENERIC labels match. If you ingested with another schema, pass that schema's labels in entity_labels.
import asyncio
from agrag.retrieval.recipes import HYBRID
from agrag.retrieval.search_engine import SearchEngine
from agrag.common.data_models.graph_schema import GENERIC
from agrag.embedding import build_embedder
from agrag.graphdb import build_graph_store
from agrag.ingestion import Graph
from agrag.ingestion.extract import GlinerExtractor
async def main() -> None:
graph_store = build_graph_store("neo4j")
embedder = build_embedder("ibm-granite/granite-embedding-small-english-r2")
graph = await Graph.open(
schema=GENERIC,
graph_store=graph_store,
embedder=embedder,
extractor=GlinerExtractor(),
)
try:
await graph.add(text="Aspirin treats headaches. Ibuprofen also treats headaches.")
engine = SearchEngine(
graph_store=graph_store,
embedder=embedder,
entity_labels=[entity.label for entity in GENERIC.entities],
)
results = await engine.search("What treats headaches?", HYBRID)
for result in results:
print(f"{result.item} (score={result.score:.3f})")
finally:
await graph_store.close()
asyncio.run(main())
Child chunks and their parents
When you chunk with ParentChildChunker, a chunk search returns child chunks. Each SearchResult has the parent chunk in SearchResult.parent. It is None for every other result. The agent sees the parent text under the citation key of the first child hit, for example [C1]. A later child of the same parent shows its own text and the note (part of [C1]). A citation always names the child. See Configure chunking for the setup.
Using build_agent
import asyncio
from agrag.agents.build import build_agent
from agrag.agents.settings import AgentLLMSettings
from agrag.retrieval.search_engine import SearchEngine
from agrag.common.data_models.graph_schema import GENERIC
from agrag.embedding import build_embedder
from agrag.graphdb import build_graph_store
from agrag.ingestion import Graph
from agrag.ingestion.extract import GlinerExtractor
async def main() -> None:
graph_store = build_graph_store("neo4j")
embedder = build_embedder("ibm-granite/granite-embedding-small-english-r2")
graph = await Graph.open(
schema=GENERIC,
graph_store=graph_store,
embedder=embedder,
extractor=GlinerExtractor(),
)
try:
await graph.add(text="Aspirin treats headaches. Ibuprofen also treats headaches.")
engine = SearchEngine(graph_store=graph_store, embedder=embedder)
agent = build_agent(
engine=engine,
llm_settings=AgentLLMSettings.from_openai_compatible_env(),
graph_schema=GENERIC,
)
result = await agent.ainvoke({
"messages": [
{"role": "user", "content": "What treats headaches?"}
]
})
last_message = result["messages"][-1]
print(last_message["content"] if isinstance(last_message, dict) else last_message.content)
finally:
await graph_store.close()
asyncio.run(main())
result["ledger"] holds the citation keys the agent assigned during the run. Call result["ledger"].resolve("C1") to get the SearchResult behind a key in the answer. It returns None for a key the run never assigned. Each call to ainvoke returns a new ledger.
The deep-agent path returns LangChain message objects, so the last message's text is .content. The optional-dependency fallback returns dictionaries, as shown above. graph_schema tells the researcher's prompt and the generated-query tool which labels and relationship types the graph uses. Omit it to use the engine's own schema, which falls back to GENERIC when the engine was built without one. If you pass a schema that differs from the engine's schema, build_agent raises ValueError. The prompt can never describe a graph that the engine does not search.
Scoping agent retrieval
By default, the agent's tools search the whole graph. Pass SearchFilters to
limit every tool search by document set, tenant, or label:
import asyncio
from agrag.agents.build import build_agent
from agrag.agents.settings import AgentLLMSettings
from agrag.retrieval.filters import SearchFilters
from agrag.retrieval.search_engine import SearchEngine
from agrag.common.data_models.graph_schema import GENERIC
from agrag.embedding import build_embedder
from agrag.graphdb import build_graph_store
from agrag.ingestion import Graph
from agrag.ingestion.extract import GlinerExtractor
async def main() -> None:
graph_store = build_graph_store("neo4j")
embedder = build_embedder("ibm-granite/granite-embedding-small-english-r2")
graph = await Graph.open(
schema=GENERIC,
graph_store=graph_store,
embedder=embedder,
extractor=GlinerExtractor(),
)
try:
await graph.add(text="Aspirin treats headaches. Ibuprofen also treats headaches.")
engine = SearchEngine(graph_store=graph_store, embedder=embedder)
agent = build_agent(
engine=engine,
llm_settings=AgentLLMSettings.from_openai_compatible_env(),
filters=SearchFilters(document_ids=["doc-1", "doc-2"]),
)
finally:
await graph_store.close()
asyncio.run(main())
Set the filters when your application builds the agent. The tools enforce the filters. The model cannot see or change them.
Build-time filters and per-call tool parameters have different roles. The
caller sets filters, and the model cannot see them. The model chooses the
tool parameters for each call, such as labels, document_ids, limit,
min_score, relation_type, and direction. These parameters can only
narrow the build-time scope. They cannot widen it. The tools refuse requests
outside the scope.
The document_ids filter applies to the five discovery tools and entity
lookup. It also limits neighbors from find_related_entities and
traverse_from_entity. The labels and properties filters apply to entity
lookup and traversal neighbors. relation_types is a traversal allowlist.
list_relationship_types uses only this allowlist. The eleventh tool,
query_graph_directly, is outside both groups. Unscoped agents receive it.
Caller-scoped agents do not.
Researcher tools
The researcher subagent receives eleven tools when the agent is unscoped, ten when caller-set base filters are non-empty. The tools fall into three groups plus a calculator.
Discovery tools find entry points into the graph:
| Tool | Purpose | Key parameters |
|---|---|---|
search_source_text | Find passages of source text that mention the query terms | query, limit, document_ids |
look_up_entity | Find entities that match a name or description | query, limit, labels |
explore_related | Search entities and source text together for a topic | query, limit, labels, document_ids |
answer_from_graph_structure | Answer from reranked entity and passage evidence | query, limit, min_score |
answer_thematic_question | Answer a broad question from community summaries | query, limit |
Structural navigation tools read what the graph stores about one entity:
| Tool | Purpose | Key parameters |
|---|---|---|
describe_entity | Show an entity's own recorded properties, not its neighbors | entity |
list_relationship_types | List the relationship types attached to an entity | entity, relation_type_filter |
Relational tools follow relationships between entities:
| Tool | Purpose | Key parameters |
|---|---|---|
find_related_entities | Find the entities one relationship type away from an entity | entity, relation_type, direction, community_expand |
traverse_from_entity | Walk outward from an entity, with a fallback to relationship types when the neighborhood is too wide | entity, relation_type, direction, depth |
query_graph_directly | Ask a structural question in one generated read-only Cypher query; present only for unscoped agents | query |
If the generated query fails, query_graph_directly returns an error message, not "No results found.", so the agent can try another tool.
compute_over_evidence stands outside the three groups. It computes a count, a sum, or a comparison over numbers the researcher already read from earlier tool results. It never queries the graph.
Available Recipes
| Recipe | Methods | BFS | Reranker |
|---|---|---|---|
ENTITY | entity | No | None |
CHUNK | chunk | No | None |
HYBRID | entity, chunk | No | None |
HYBRID_RERANKED | entity, chunk | No | cross_encoder |
GRAPH_EXPAND | entity | Yes | None |
THEMATIC | community | No | None |
TEXT2CYPHER | text2cypher | No | None |
Configuration
Set environment variables with the RETRIEVAL_ prefix:
RETRIEVAL_ENTITY_LABELS='["Drug","Condition"]'
RETRIEVAL_ENTITY_TOP_K=10
RETRIEVAL_CHUNK_TOP_K=10
RETRIEVAL_HYBRID_ALPHA=0.5
RETRIEVAL_TRAVERSAL_DEPTH=2
RETRIEVAL_ENTITY_LABELS (or the entity_labels argument) names the schema
entity labels that entity search runs against. Ingestion creates one native
vector index per label, so search needs the labels themselves. The
RETRIEVAL_ENTITY_COLLECTION name does not apply here. It applies only when
you configure a separate VectorStore.