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. With strict=True, gpt-4o-mini often returns zero triplets when the schema has many types; strict=False still 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 of pg_execute_server_program — is refused with a ConfigurationError. Connect the store as a role that holds neither, like the app role in Installation. To accept such a role anyway, say so explicitly with SafeTextToCypherRetriever(..., allow_server_programs=True), or store.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.

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

01_arxiv_pg

Property graph + vector search + GraphRAG over arXiv

prepare.py → query.py

02_wikipedia_pgindex

LLM-extracted KG + full retriever stack + Text2Cypher

build.py → ask.py

03_news_vector_rag

Semantic, metadata-filtered, hybrid, and cited RAG

ingest.py → rag.py

04_router

One AgensEngine routing graph-vs-vector, as a router and a FunctionAgent

router.py

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.