Graph RAG
When to Use Graph RAG vs Vector RAG
| Signal | Graph RAG | Vector RAG |
|---|---|---|
| Multi-hop questions ("who reports to X's manager?") | Yes | No |
| Entity-centric queries with fan-out | Yes | Partial |
| Global/thematic questions over a corpus | GraphRAG community summaries | No |
| Local "find me similar text" lookup | Overkill | Yes |
| Structured relations already exist (SQL, CRM) | Strong fit | Weak |
| Low-latency, high-QPS chat | Hybrid (graph for reasoning, vector for context) | Yes |
Rule of thumb: use graph RAG when the answer requires connecting pieces of information across documents; use vector RAG when the answer lives inside a single chunk.
Entity and Triple Extraction
# Using Claude to extract (subject, predicate, object) triples
import anthropic, json, re
client = anthropic.Anthropic()
EXTRACT_PROMPT = """Extract all factual triples from the text as JSON.
Schema: [{"s": "...", "p": "...", "o": "...", "s_type": "Person|Org|Product|Concept", "o_type": "..."}]
Only extract facts explicitly stated. Normalize entity names (no pronouns).
Text:
{text}
Return JSON only."""
def extract_triples(text: str) -> list[dict]:
msg = client.messages.create(
model="claude-opus-4-5",
max_tokens=2000,
messages=[{"role": "user", "content": EXTRACT_PROMPT.format(text=text)}],
)
raw = msg.content[0].text
return json.loads(re.search(r"\[.*\]", raw, re.DOTALL).group(0))
For high-volume extraction, use a cheaper model (claude-haiku-4-5 or gpt-4o-mini) and batch 10-20 chunks per call. Cache by sha256(chunk) to avoid re-extraction.
Neo4j + Embeddings Hybrid Store
from neo4j import GraphDatabase
from openai import OpenAI
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "pass"))
oai = OpenAI()
CREATE_SCHEMA = """
CREATE CONSTRAINT entity_name IF NOT EXISTS FOR (e:Entity) REQUIRE e.name IS UNIQUE;
CREATE VECTOR INDEX entity_embed IF NOT EXISTS
FOR (e:Entity) ON (e.embedding)
OPTIONS {indexConfig: {`vector.dimensions`: 1536, `vector.similarity_function`: 'cosine'}};
"""
def embed(text: str) -> list[float]:
return oai.embeddings.create(model="text-embedding-3-small", input=text).data[0].embedding
def upsert_triple(tx, s, p, o, s_type, o_type, source_chunk_id):
tx.run("""
MERGE (a:Entity {name: $s}) SET a.type = $s_type, a.embedding = $s_emb
MERGE (b:Entity {name: $o}) SET b.type = $o_type, b.embedding = $o_emb
MERGE (a)-[r:REL {type: $p}]->(b)
SET r.source = $src
""", s=s, o=o, p=p, s_type=s_type, o_type=o_type,
s_emb=embed(s), o_emb=embed(o), src=source_chunk_id)
with driver.session() as s:
s.run(CREATE_SCHEMA)
for t in triples:
s.execute_write(upsert_triple, t["s"], t["p"], t["o"],
t["s_type"], t["o_type"], chunk_id)
Graph-augmented retrieval (entity seed + n-hop expansion)
// Find entities semantically similar to the query, then expand 2 hops
CALL db.index.vector.queryNodes('entity_embed', 5, $query_embedding)
YIELD node AS seed, score
MATCH path = (seed)-[*1..2]-(neighbor:Entity)
WITH seed, neighbor, score, [r IN relationships(path) | r.type] AS rels
RETURN seed.name, neighbor.name, rels, score
ORDER BY score DESC LIMIT 30;
Microsoft GraphRAG (Community Summarization)
GraphRAG's insight: after building the entity graph, run hierarchical community detection (Leiden), summarize each community with an LLM, and use those summaries for global queries.
# pip install graphrag
# graphrag init --root ./ragtest
# Configure settings.yaml with OpenAI/Azure keys, then:
# graphrag index --root ./ragtest
#
# Query modes:
# graphrag query --method local "What did Alice work on?"
# graphrag query --method global "Summarize all regulatory themes in the corpus"
# graphrag query --method drift "How do safety issues relate to recalls?"
from graphrag.query.structured_search.local_search.search import LocalSearch
from graphrag.query.structured_search.global_search.search import GlobalSearch
# Local = entity-centric (start from entities matching the query, walk graph)
# Global = map-reduce over community summaries (thematic / corpus-wide)
# DRIFT = hybrid: local seed, global expansion for connective tissue
When to pick which mode:
- Local: "What's X's role?", "Who did X work with?" - starts from named entities.
- Global: "Main themes", "Recurring risks", "Summarize the corpus" - needs community reports.
- DRIFT: multi-hop exploratory questions where local and global both matter.
HippoRAG (Personalized PageRank)
HippoRAG builds a passage-to-entity graph once, then at query time:
- NER on query to find seed entities.
- Run Personalized PageRank (PPR) with seeds as the teleport distribution.
- Rank passages by aggregated PPR mass of their entities.
# pip install hipporag
from hipporag import HippoRAG
hipporag = HippoRAG(
save_dir="./hippo_index",
llm_model_name="gpt-4o-mini",
embedding_model_name="nvidia/NV-Embed-v2",
)
hipporag.index(docs=["passage 1...", "passage 2...", "passage 3..."])
answers = hipporag.rag_qa(queries=["Which researcher's student works on X?"])
Use HippoRAG when you need single-step multi-hop retrieval without building explicit Cypher queries.
LlamaIndex Property Graph Index
from llama_index.core import PropertyGraphIndex, Document
from llama_index.core.indices.property_graph import (
SchemaLLMPathExtractor, ImplicitPathExtractor,
)
from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore
from llama_index.llms.anthropic import Anthropic
from llama_index.embeddings.openai import OpenAIEmbedding
from typing import Literal
Entities = Literal["Person", "Org", "Product", "Concept"]
Relations = Literal["WORKS_AT", "FOUNDED", "USES", "RELATED_TO"]
graph_store = Neo4jPropertyGraphStore(
username="neo4j", password="pass", url="bolt://localhost:7687",
)
index = PropertyGraphIndex.from_documents(
[Document(text=t) for t in corpus],
property_graph_store=graph_store,
kg_extractors=[
SchemaLLMPathExtractor(
llm=Anthropic(model="claude-opus-4-5"),
possible_entities=Entities,
possible_relations=Relations,
strict=True, # drop triples not matching schema
),
ImplicitPathExtractor(), # adds MENTIONS edges from chunks to entities
],
embed_model=OpenAIEmbedding(model="text-embedding-3-small"),
show_progress=True,
)
# Query with graph + vector hybrid
retriever = index.as_retriever(
include_text=True,
similarity_top_k=10,
path_depth=2, # expand 2 hops from seed entities
)
nodes = retriever.retrieve("Which founders worked at companies that use Kafka?")
LangChain Graph Retriever
from langchain_neo4j import Neo4jGraph, GraphCypherQAChain
from langchain_anthropic import ChatAnthropic
graph = Neo4jGraph(url="bolt://localhost:7687", username="neo4j", password="pass")
graph.refresh_schema()
chain = GraphCypherQAChain.from_llm(
llm=ChatAnthropic(model="claude-opus-4-5"),
graph=graph,
verbose=True,
allow_dangerous_requests=True, # LLM writes Cypher; restrict DB user to read-only
validate_cypher=True,
return_intermediate_steps=True,
)
result = chain.invoke({"query": "Who reports to Alice's manager?"})
# Inspect result["intermediate_steps"][0]["query"] for the generated Cypher
Safety: always give the Neo4j role only MATCH/read privileges; never let an LLM write or delete.
Hybrid Graph + Vector Pipeline
def hybrid_retrieve(query: str, k_vec: int = 10, k_graph: int = 10):
# 1. Semantic top-K from chunk vector index
vec_hits = vectorstore.similarity_search(query, k=k_vec)
# 2. Graph traversal seeded by query entities
entities = extract_entities(query)
with driver.session() as s:
graph_hits = s.run("""
MATCH (e:Entity) WHERE e.name IN $ents
MATCH (e)-[*1..2]-(n:Entity)<-[:MENTIONS]-(c:Chunk)
RETURN DISTINCT c.id AS id, c.text AS text
LIMIT $k
""", ents=entities, k=k_graph).data()
# 3. Rerank the union with Cohere or a cross-encoder
return rerank(query, vec_hits + graph_hits, top_n=6)
Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| Extracting triples with no schema | Constrain entity/relation types via SchemaLLMPathExtractor |
| LLM-generated Cypher with write access | Grant read-only role; validate with validate_cypher |
| No entity normalization (Alice vs A. Smith) | Canonicalize with embedding clustering + name-matching |
| GraphRAG with tiny corpus | Community summaries add little under ~50 docs; use local mode only |
| Building graph every query | Index once; incrementally update on new docs |
| Ignoring provenance | Every edge must store source_chunk_id for citations |
Production Checklist
- Schema for entities and relations documented and enforced
- Entity canonicalization step (dedup aliases before insert)
- Provenance (source chunk + offsets) stored on every edge
- Read-only DB credentials for LLM-generated Cypher
- Incremental graph updates on doc ingest (no full rebuilds in prod)
- Hybrid retriever (graph + vector + rerank) benchmarked vs vector-only
- Community summaries refreshed on a schedule (GraphRAG)
- Latency budget: graph traversal capped (e.g.,
LIMIT 50, depth <= 2)