The llamaindex propertygraphindex tutorial below is the one I wish existed when I first wired a knowledge graph into a RAG pipeline. PropertyGraphIndex turns unstructured text into entities and typed relationships with properties, then lets you query with a mix of graph traversal and vector similarity. It solves multi-hop reasoning problems that flat vector search silently fails.
What PropertyGraphIndex buys you
Vector indexes answer “which chunks look like this query”. PropertyGraphIndex answers “what is the relationship between X and Y, and what properties does that edge carry”. Under the hood it extracts nodes (entities) and edges (relationships) using an LLM, stores them in a property graph store, and attaches embeddings to nodes and source text for hybrid retrieval.
The index is not a replacement for a vector store; it is a superset. You get three retrievers out of the box: a vector retriever over source text, a graph retriever that walks edges, and a keyword retriever. Combining them is where the value shows.
Setup and dependencies
This llamaindex propertygraphindex tutorial assumes LlamaIndex v0.11 or newer, where the API is stable. Install the core package plus a model provider and an optional graph store.
pip install llama-index-core llama-index-llms-openai llama-index-embeddings-openai
# only if you need persistent storage
pip install llama-index-graph-stores-nebula
Set credentials:
import os
os.environ["OPENAI_API_KEY"] = "sk-..."
Step 1: Load and chunk documents
Do not feed whole PDFs. Chunk at a size the extractor can handle without dropping entities. from_documents applies a default SentenceSplitter at 1024 tokens; override if your domain is dense.
from llama_index.core import SimpleDirectoryReader
documents = SimpleDirectoryReader("./data").load_data()
for d in documents:
d.metadata["source"] = d.metadata.get("file_path", "unknown")
Step 2: Configure the model and extractors
PropertyGraphIndex uses Settings.llm to extract triplets. The default pipeline runs an EntityExtractor and an ImplicitPathExtractor. Start with defaults, then constrain.
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
Settings.llm = OpenAI(model="gpt-4o-mini")
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
A common mistake is using a weak model at extract time to save cost. Entity resolution suffers and your graph becomes a starburst of near-duplicate nodes. Pay for a capable model when building the index; query time can use cheaper ones.
Step 3: Build the index
The simplest path is in-memory:
from llama_index.core.indices.property_graph import PropertyGraphIndex
index = PropertyGraphIndex.from_documents(
documents,
show_progress=True,
property_graph_store=None, # defaults to SimplePropertyGraphStore
)
This makes one LLM call per chunk for extraction plus embedding calls. For 1k chunks expect a few thousand requests. Run it offline, never in a request path.
Step 4: Choose a storage backend
The default store is ephemeral. For production, use Neo4j or NebulaGraph.
from llama_index.graph_stores.nebula import NebulaPropertyGraphStore
store = NebulaPropertyGraphStore(
space="llama_space",
username="root",
password="nebula",
url="127.0.0.1",
port=9669,
)
index = PropertyGraphIndex.from_documents(documents, property_graph_store=store)
Tradeoff: external graph stores add ops burden but give you Cypher access for debugging. I keep a small in-memory build in unit tests to catch extraction regressions.
Step 5: Query with hybrid retrieval
The graph retriever alone misses lexical matches; the vector retriever alone misses structure. Use both.
retriever = index.as_retriever(
sub_retrievers=[index.property_graph_retriever, index.vector_retriever],
retriever_weights=[0.5, 0.5],
)
nodes = retriever.retrieve("Who reports to the CTO and what teams do they own?")
For synthesized answers:
query_engine = index.as_query_engine()
response = query_engine.query("Trace the ownership chain from the acquirer to the target asset.")
print(response)
How extraction actually works
Each chunk is sent to the LLM with a prompt that asks for subjects, objects, and predicates. The result is normalized into Node and Relationship objects with arbitrary properties. Source text is kept as a node property so vector retrieval can fall back to the original chunk. If you need tighter control, pass a custom kg_extractors list:
from llama_index.core.extractors import EntityExtractor
extractor = EntityExtractor(
labels=["PERSON", "ORG", "PRODUCT"],
llm=Settings.llm,
)
index = PropertyGraphIndex.from_documents(
documents,
kg_extractors=[extractor],
)
Common pitfalls
Hallucinated edges
LLMs invent relationships not stated in text. PropertyGraphIndex does not score edges by default. Add a confidence property in a custom extractor and drop edges below a threshold.
Entity explosion
Without resolution, “CEO”, “chief executive”, and “Alice Smith” become three nodes. Use the EntityExtractor with extract_metadata and a resolution step, or accept the noise for prototypes.
Cost and latency
Extraction is O(chunks × LLM calls). At 5000 chunks with a mini model you will spend a few dollars and wait minutes. Cache the built graph. Never rebuild on every query.
Schema drift
Property graphs are schemaless. Over time your edges mean different things. Document core entity types in a README or you will drown in (:Thing)-[:RELATED]->(:Thing).
Tradeoffs vs vanilla VectorStoreIndex
Unlike the simplified view in some llamaindex propertygraphindex tutorial snippets, the hybrid retriever needs tuning. Use PropertyGraphIndex when:
- Questions require traversing relationships (org charts, supply chains, dependency graphs).
- You need to filter by properties (date ranges, statuses).
- You can afford offline indexing.
Stick with VectorStoreIndex when:
- Queries are pure semantic similarity (“find docs like this”).
- You have fewer than 100 docs and no relational structure.
- Latency budget forbids graph walks.
Advanced: custom property filters
You can query the graph store directly with Cypher for precise cuts:
cypher = "MATCH (p:Person)-[:OWNS]->(a:Asset) WHERE a.value > 1000000 RETURN p"
res = index.property_graph_store.query(cypher)
This bypasses the LLM retriever and is fast. Wrap it in a custom retriever for hybrid logic when the graph shape is known.
Production checklist
- Chunk docs at 512–1024 tokens.
- Use a strong LLM for extraction; cache the graph artifact.
- Pick a persistent graph store before scaling past toy data.
- Weigh retriever outputs; tune weights on a held-out eval set.
- Add confidence properties to edges if hallucination is high.
- Monitor node count; alert on entity explosion.
The llamaindex propertygraphindex tutorial above is a starting line, not a finish. The graph is only as good as your extractor and your willingness to prune it.