Usage#
This is a hands-on guide to llama-index-agensgraph 0.3.1. It assumes you have completed
Installation — a running AgensGraph 2.17 or later with the pgvector
extension. The examples that call a model also need OPENAI_API_KEY; the ones that pass in
embeddings by hand do not.
The integration plugs into LlamaIndex’s standard indices. Import paths:
from llama_index_agensgraph.graph_stores.agensgraph import AgensPropertyGraphStore
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
from llama_index_agensgraph.retrievers import SafeTextToCypherRetriever
from llama_index_agensgraph.engine import AgensEngine
The property graph store writes each node on a label named after its type — a Person
entity on the vertex label "Person", a text chunk on "Chunk" — and every one of those
labels inherits from "__Node__". MATCH (n:"Person") reads only that label’s storage,
MATCH (n:"__Node__") still reaches every node, and label(n) returns a node’s type. The
embedding is kept in a column of its own rather than in the property map, when the store is
given a vector_dimension and the server can give a property a column. The store’s
Text2Cypher prompt describes this layout, so generated Cypher matches it.
Every statement goes through the
agensgraph-python 2.0 driver. Both stores
implement their async methods (aquery, async_add, aupsert_nodes, avector_query,
astructured_query, …) on the driver’s async connection instead of running the synchronous
ones on the event loop.
Quickstart#
The simplest “it works”: a VectorStoreIndex on AgensgraphVectorStore. The store creates
the graph, the HNSW index, and CREATE EXTENSION IF NOT EXISTS vector on construction.
import os
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.core.schema import TextNode
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
os.environ["OPENAI_API_KEY"] = "sk-..."
url = "postgresql://app:app@localhost:5432/mydb"
vector_store = AgensgraphVectorStore(url=url, embedding_dimension=1536) # match your model
nodes = [
TextNode(text="The Shawshank Redemption", metadata={"theme": "Friendship", "year": 1994}),
TextNode(text="Inception", metadata={"theme": "Fiction", "year": 2010}),
TextNode(text="1984", metadata={"theme": "Totalitarianism", "year": 1949}),
]
Index the nodes and ask a question. VectorStoreIndex embeds each node with the default
OpenAI embedding model and writes it to the store:
index = VectorStoreIndex(nodes, storage_context=StorageContext.from_defaults(vector_store=vector_store))
print(index.as_query_engine().query("What is a movie about dreams?"))
url and embedding_dimension are required; embedding_dimension must match your model
(1536 for text-embedding-3-small).
Property graph index#
Let an LLM extract a typed knowledge graph from documents with SchemaLLMPathExtractor,
store it in AgensGraph, and query it.
from typing import Literal
from llama_index.core import Document, PropertyGraphIndex
from llama_index.core.indices.property_graph import SchemaLLMPathExtractor
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index_agensgraph.graph_stores.agensgraph import AgensPropertyGraphStore
conf = {"dbname": "mydb", "user": "app", "password": "app", "host": "localhost", "port": 5432}
graph_store = AgensPropertyGraphStore(
graph_name="li_kg",
conf=conf,
vector_dimension=1536, # builds the HNSW entity index; omit to skip it (slower search)
enhanced_schema=True, # richer property stats for Text2Cypher
create=True, create_indexes=True, refresh_schema=True,
)
documents = [
Document(text="Ada Lovelace, born in London, worked with Charles Babbage on the Analytical Engine."),
Document(text="Babbage founded the Analytical Society in Cambridge."),
]
Then extract the graph with a schema you choose, and query it:
llm = OpenAI(model="gpt-4o-mini", temperature=0.0)
extractor = SchemaLLMPathExtractor(
llm=llm,
possible_entities=Literal["Person", "Organization", "Place", "Work"],
possible_relations=Literal["FOUNDED", "LOCATED_IN", "BORN_IN", "CREATED", "MEMBER_OF"],
strict=False, # see the note below
max_triplets_per_chunk=10,
)
index = PropertyGraphIndex.from_documents(
documents,
property_graph_store=graph_store,
embed_model=OpenAIEmbedding(model_name="text-embedding-3-small"),
llm=llm,
kg_extractors=[extractor],
embed_kg_nodes=True,
show_progress=True,
)
print(index.as_query_engine(include_text=True).query("Who worked on the Analytical Engine?"))
SchemaLLMPathExtractor upper-cases the types it is given, so this graph holds "PERSON",
"WORK" and "ORGANIZATION" labels: match (p:"PERSON") on it, not (p:"Person").
strict=False. Withstrict=True,gpt-4o-minioften returns zero triplets when the schema has many types;strict=Falsestill steers the model with your ontology but reliably produces a graph.
To attach to an already-populated graph without re-extracting:
index = PropertyGraphIndex.from_existing(
property_graph_store=graph_store,
embed_model=OpenAIEmbedding(model_name="text-embedding-3-small"), llm=llm,
kg_extractors=[],
)
Working with the graph store directly#
PropertyGraphIndex drives the store through LlamaIndex’s PropertyGraphStore interface,
and you can call the same methods yourself — to load entities you already have, or to look at
what an extractor wrote. The embeddings below are three numbers long to keep the example
short; in practice they come from your embedding model, and vector_dimension matches it.
from llama_index.core.graph_stores.types import EntityNode, Relation
from llama_index_agensgraph.graph_stores.agensgraph import AgensPropertyGraphStore
conf = {"dbname": "mydb", "user": "app", "password": "app", "host": "localhost", "port": 5432}
store = AgensPropertyGraphStore(graph_name="li_people", conf=conf, vector_dimension=3)
ada = EntityNode(name="Ada Lovelace", label="Person", properties={"born": 1815},
embedding=[1.0, 0.0, 0.0])
babbage = EntityNode(name="Charles Babbage", label="Person", embedding=[0.9, 0.1, 0.0])
analytical = EntityNode(name="Analytical Engine", label="Work", embedding=[0.0, 1.0, 0.0])
store.upsert_nodes([ada, babbage, analytical])
store.upsert_relations([
Relation(label="WORKED_ON", source_id=ada.id, target_id=analytical.id),
Relation(label="CREATED", source_id=babbage.id, target_id=analytical.id),
])
print(store.get(ids=[ada.id]))
print(store.get_triplets(entity_names=["Ada Lovelace"]))
print(store.structured_query(
'MATCH (p:"Person")-[r]->(w:"Work") RETURN p.name AS who, type(r) AS rel, w.name AS what'
))
print(store.get_schema_str(refresh=True))
Each type is written on its own label, so counting by type is a match on the labels:
print(store.structured_query('MATCH (n:"__Node__") RETURN label(n) AS type, count(*) AS n'))
Prefer count(*) over count(n): count(n) materializes each matched node, embedding
included.
vector_query ranks entities by their embedding, and honors MetadataFilters (see
Metadata-filtered search):
from llama_index.core.vector_stores.types import VectorStoreQuery
nodes, scores = store.vector_query(
VectorStoreQuery(query_embedding=[1.0, 0.05, 0.0], similarity_top_k=2)
)
print([n.name for n in nodes], scores)
To load a large corpus, wrap the writes in bulk_ingest(). It drops the vector indexes for
the duration of the block, then builds them again and collects statistics when the block
ends. A search inside the block reads every element — for every user of the graph, not only
this one — so it is for loading, not for a store that is answering questions.
with store.bulk_ingest():
store.upsert_nodes([
EntityNode(name=f"Author {i}", label="Person", embedding=[0.1 * i, 0.2, 0.3])
for i in range(100)
])
A filtered search cannot use the HNSW index for the filter itself, so index the keys you
filter on with store.create_property_index("country"). The index is created on every label
that holds elements, and on labels written later.
Text2Cypher retrieval#
TextToCypherRetriever turns a question into AgensGraph Cypher using the store’s built-in
dialect prompt — no custom prompt needed. strip_markdown takes the statement out of the code
fence a model tends to wrap it in:
from llama_index.core.indices.property_graph import TextToCypherRetriever
from llama_index.llms.openai import OpenAI
from llama_index_agensgraph.retrievers import strip_markdown
retriever = TextToCypherRetriever(
graph_store=graph_store, llm=OpenAI(model="gpt-4o-mini"),
cypher_validator=strip_markdown, # a fenced answer is otherwise a syntax error
)
nodes = retriever.retrieve("How many entities of each type are there?")
print(nodes[0].node.text) # the generated Cypher and its result
TextToCypherRetriever runs whatever the model wrote, with nothing stopping a write. For
anything beyond a trusted notebook, use SafeTextToCypherRetriever. It runs the generated
statement in a read-only transaction, so the server refuses a write however it is spelled,
and it holds the text to a single statement. When the statement fails, it logs a warning and
returns no nodes instead of aborting the whole query engine:
from llama_index.llms.openai import OpenAI
from llama_index_agensgraph.retrievers import SafeTextToCypherRetriever, strip_markdown
retriever = SafeTextToCypherRetriever(
graph_store=graph_store, llm=OpenAI(model="gpt-4o-mini"),
cypher_validator=strip_markdown,
)
nodes = retriever.retrieve("Which people worked on the Analytical Engine?")
Connect as an ordinary role. A read-only transaction does not stop
COPY ... TO PROGRAM, so a role that may run a command on the server’s host — a superuser, or a member ofpg_execute_server_program— is refused with aConfigurationError. Connect the store as a role that holds neither, like theapprole in Installation. To accept such a role anyway, say so explicitly withSafeTextToCypherRetriever(..., allow_server_programs=True), orstore.read_only(allow_server_programs=True)on the store. The repository’s demos do, because they connect as a superuser to a database they own.
The same read-only block is available on the store, for any Cypher you did not write yourself:
from llama_index_agensgraph.graph_stores.agensgraph.utils import AgensQueryException
with store.read_only():
print(store.structured_query('MATCH (p:"Person") RETURN count(*) AS n'))
try:
store.structured_query("""CREATE (:"Person" {name: 'Mallory'})""")
except AgensQueryException as e:
print(e.details) # 25006: cannot write to a graph in a read-only transaction
Vector store#
Build a VectorStoreIndex over documents, or reopen an existing store without re-ingesting:
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex, StorageContext
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
vector_store = AgensgraphVectorStore(url=url, embedding_dimension=1536)
documents = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(
documents, storage_context=StorageContext.from_defaults(vector_store=vector_store)
)
print(index.as_query_engine().query("What happened at Interleaf?"))
# Reopen later without re-embedding:
index = VectorStoreIndex.from_vector_store(AgensgraphVectorStore(url=url, embedding_dimension=1536))
Constructor knobs (defaults): graph_name="vector_store", index_name="vector",
keyword_index_name="keyword", node_label="Chunk", embedding_node_property="embedding",
text_node_property="text", distance_strategy="cosine" (or "l2", its alias
"euclidean", and "inner_product"),
hybrid_search=False, promote_embedding=True, engine=None. promote_embedding keeps the
embedding in a column of its own; False leaves it in the property map.
The store can also be used without an index, with embeddings you computed yourself. The next two sections use it this way, with three-number embeddings:
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.types import VectorStoreQuery
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
url = "postgresql://app:app@localhost:5432/mydb"
movies = AgensgraphVectorStore(url=url, embedding_dimension=3, graph_name="li_movies",
hybrid_search=True)
movies.add([
TextNode(id_="shawshank", text="The Shawshank Redemption, a story of friendship in prison",
metadata={"theme": "Friendship", "year": 1994}, embedding=[1.0, 0.0, 0.0]),
TextNode(id_="inception", text="Inception, a thief who steals secrets through dreams",
metadata={"theme": "Fiction", "year": 2010}, embedding=[0.0, 1.0, 0.0]),
TextNode(id_="1984", text="1984, a novel of surveillance and totalitarian rule",
metadata={"theme": "Totalitarianism", "year": 1949}, embedding=[0.0, 0.0, 1.0]),
])
result = movies.query(VectorStoreQuery(query_embedding=[0.1, 0.9, 0.0], similarity_top_k=2))
print([n.node_id for n in result.nodes], result.similarities)
bulk_ingest() works here as it does on the graph store: with movies.bulk_ingest(): around
the add calls drops the vector index for the block and builds it after.
Metadata-filtered search#
Scope semantic search with LlamaIndex’s standard MetadataFilters, translated to
parameterized Cypher. Through an index, pass them as index.as_retriever(filters=filters);
on the store, put them on the VectorStoreQuery:
from llama_index.core.vector_stores import (
MetadataFilter, MetadataFilters, FilterOperator, FilterCondition,
)
filters = MetadataFilters(
filters=[
MetadataFilter(key="theme", value="Fiction"), # EQ is the default
MetadataFilter(key="year", value=1997, operator=FilterOperator.GT),
],
condition=FilterCondition.AND, # or FilterCondition.OR
)
result = movies.query(
VectorStoreQuery(query_embedding=[0.1, 0.9, 0.0], similarity_top_k=2, filters=filters)
)
print([n.node_id for n in result.nodes]) # ['inception']
All 14 operators are supported — EQ, NE, GT, GTE, LT, LTE, IN, NIN, CONTAINS, TEXT_MATCH, TEXT_MATCH_INSENSITIVE, ANY, ALL, IS_EMPTY — plus AND/OR/NOT and nested filter groups.
A filtered predicate can’t use the HNSW index, so index the keys you filter on:
movies.create_property_index("theme")
The same MetadataFilters objects also drive get_nodes(filters=...) and
delete_nodes(filters=...):
old = MetadataFilters(filters=[MetadataFilter(key="year", value=1990, operator=FilterOperator.LT)])
print([n.node_id for n in movies.get_nodes(filters=old)]) # ['1984']
Hybrid search and other query modes#
The store answers the mode a query asks for: default (vector search), hybrid (and
semantic_hybrid), text_search (and sparse), and mmr. Hybrid search needs a store
built with hybrid_search=True, which adds a full-text index; asking a store built without
one raises ValueError. Text search runs on any store, but without that index it reads every
row.
Hybrid search queries each modality against its own index (HNSW and full-text) and fuses
the two rankings by reciprocal rank fusion. alpha weighs the vector side: 1.0 is the
vector search alone, 0.0 the text search alone, and the default is 0.5. sparse_top_k
sets how many candidates the text side returns, and hybrid_top_k how many fused results
come back. A metadata filter applies to both halves.
from llama_index.core.vector_stores.types import VectorStoreQueryMode
hybrid = movies.query(VectorStoreQuery(
query_embedding=[0.1, 0.9, 0.0], query_str="dreams", similarity_top_k=2,
mode=VectorStoreQueryMode.HYBRID, alpha=0.5,
))
text = movies.query(VectorStoreQuery(
query_str="friendship", similarity_top_k=2, mode=VectorStoreQueryMode.TEXT_SEARCH,
))
diverse = movies.query(VectorStoreQuery(
query_embedding=[0.1, 0.9, 0.0], similarity_top_k=2,
mode=VectorStoreQueryMode.MMR, mmr_threshold=0.5,
))
print([n.node_id for n in hybrid.nodes], [n.node_id for n in text.nodes],
[n.node_id for n in diverse.nodes])
Through an index, the same options are retriever arguments:
index.as_retriever(similarity_top_k=5, vector_store_query_mode="hybrid", alpha=0.5).
Connection pooling and async#
By default each store runs its synchronous methods on one connection of its own, and its
async methods open a connection per call (up to 10 at once) and close it afterwards. For
concurrent workloads, share an AgensEngine — a driver connection pool, sync and async —
across stores, so each request borrows a connection that is already open. The async methods
draw from the engine’s async pool:
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.types import VectorStoreQuery
from llama_index_agensgraph.engine import AgensEngine
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
url = "postgresql://app:app@localhost:5432/mydb"
pool = AgensEngine.from_url(url, min_size=2, max_size=20) # or .from_conf(conf, ...)
notes = AgensgraphVectorStore(url=url, embedding_dimension=3, graph_name="li_notes", engine=pool)
await notes.async_add([TextNode(id_="n1", text="pooled", embedding=[1.0, 0.0, 0.0])])
found = await notes.aquery(VectorStoreQuery(query_embedding=[1.0, 0.0, 0.0], similarity_top_k=1))
print([n.node_id for n in found.nodes])
await notes.aclose()
await pool.aclose() # pool.close() when only the sync pool was used
One engine can serve a graph store and vector stores on different graphs, which a
RouterQueryEngine can then route between. The graphs here are the demos’: arxiv is built
by 01_arxiv_pg (the store opens it with create=False, so it must exist) and news by
03_news_vector_rag. Both are in the demos’ database, llamaindex_demos by default, so point
url and conf at it:
from llama_index.core import PropertyGraphIndex, VectorStoreIndex
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector
from llama_index.core.tools import QueryEngineTool
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index_agensgraph.engine import AgensEngine
from llama_index_agensgraph.graph_stores.agensgraph import AgensPropertyGraphStore
from llama_index_agensgraph.vector_stores.agensgraph import AgensgraphVectorStore
llm, embed = OpenAI(model="gpt-4o-mini"), OpenAIEmbedding(model_name="text-embedding-3-small")
engine = AgensEngine.from_url(url, min_size=2, max_size=20)
graph_qe = PropertyGraphIndex.from_existing(
property_graph_store=AgensPropertyGraphStore("arxiv", conf=conf, vector_dimension=1536,
create=False, engine=engine),
embed_model=embed, llm=llm, kg_extractors=[],
).as_query_engine()
news_qe = VectorStoreIndex.from_vector_store(
AgensgraphVectorStore(url=url, embedding_dimension=1536, graph_name="news",
node_label="Article", engine=engine),
embed_model=embed,
).as_query_engine(similarity_top_k=5)
router = RouterQueryEngine.from_defaults(
query_engine_tools=[
QueryEngineTool.from_defaults(graph_qe, name="papers", description="research papers and authors"),
QueryEngineTool.from_defaults(news_qe, name="news", description="current events"),
],
selector=LLMSingleSelector.from_defaults(llm=llm), llm=llm,
)
try:
print(router.query("What are companies doing with AI?"))
finally:
engine.close() # await engine.aclose() after async use
Run the demos#
The repository ships a runnable demo suite, each with a pre-executed notebook.
Demo |
What it shows |
Scripts |
|---|---|---|
|
Property graph + vector search + GraphRAG over arXiv |
|
|
LLM-extracted KG + full retriever stack + Text2Cypher |
|
|
Semantic, metadata-filtered, hybrid, and cited RAG |
|
|
One |
|
Run each script from the llama-index/ directory, and run 01_arxiv_pg and
03_news_vector_rag before 04_router. Standalone notebooks live under
examples/vector_stores/
(vector, hybrid, metadata filtering) and
examples/property_graph/.
Install examples/demos/requirements-demos.txt, copy examples/demos/.env.example to
examples/demos/.env, and set connection settings and OPENAI_API_KEY there; the datasets stream
from Hugging Face. Start with small *_LIMIT values and scale up once a demo runs end to end.