Build knowledge graphs from documents for better retrieval using entity extraction, relationships, and community detection.
Overview
GraphRAG enhances traditional vector search by building a knowledge graph from your documents. This allows:
- Entity-based retrieval - Find chunks by following entity relationships
- Community summaries - High-level context about clusters of related entities
- Fusion search - Combine vector and graph results with Reciprocal Rank Fusion
Quick Start
Once installed and configured, GraphRAG works with the existing API:
# Ingest with graph building - extracts entities and relationships automatically
{:ok, document} = Arcana.ingest(content, repo: MyApp.Repo, graph: true)
# Search with fusion - combines vector similarity + entity graph traversal
{:ok, results} = Arcana.search("Who leads OpenAI?", repo: MyApp.Repo, graph: true)When graph: true is enabled:
- Ingest extracts entities (people, organizations, etc.) and relationships from each chunk
- Search finds entities in your query, traverses the graph, and combines results with vector search using Reciprocal Rank Fusion (RRF)
Traversal Depth at Query Time
By default, graph-enhanced search retrieves only chunks that directly mention
the entities matched from your query. The graph_depth option expands matched
entities through the relationships table for N hops before fetching chunks, so
a query matching entity A can also retrieve chunks about A's neighbors:
# Direct mentions only (default)
{:ok, results} = Arcana.search("Who leads OpenAI?", repo: MyApp.Repo, graph: true)
# Also retrieve chunks mentioning one-hop neighbors of matched entities
{:ok, results} = Arcana.search("Who leads OpenAI?",
repo: MyApp.Repo,
graph: true,
graph_depth: 1
)
# Works for ask/2 too: expands retrieval AND includes relationships from
# matched entities to their neighbors in the prompt context
{:ok, answer, context} = Arcana.ask("What applies to the thing covered by X?",
repo: MyApp.Repo,
llm: "openai:gpt-4o-mini",
graph: true,
graph_depth: 1
)Chunks reached through traversal are down-weighted per hop: a chunk's graph
score is mentions * 0.1 * query_depth_decay^hop (decay 0.5 by default), so a
neighbor chunk always scores below an equally-mentioned direct chunk. It does
not rank below every direct chunk — a hop-1 chunk mentioning a neighbor
three times (3 * 0.1 * 0.5 = 0.15) still outscores a direct chunk mentioned
once (0.1). Lower query_depth_decay if you want traversal to weigh less.
Expansion respects collection scoping (entities from other collections are
never pulled in) and honors :source_id.
Set a global default and tune the decay in config (per-call graph_depth
wins):
config :arcana,
graph: [
enabled: true,
query_depth: 1, # hops to expand matched entities (default: 0)
query_depth_decay: 0.5 # score decay per hop for traversed chunks
]Depths of 1-2 are usually enough; each hop widens retrieval and costs one
extra query. Note this is different from the depth: option of the in-memory
Arcana.Graph.search/traverse API further down this guide — graph_depth is
the option for the Arcana.search/2 and Arcana.ask/2 query path.
Installation
GraphRAG requires additional database tables. Install them separately:
mix arcana.graph.install
mix ecto.migrate
This creates tables for entities, relationships, entity mentions, and communities.
Add the NER serving to your supervision tree for entity extraction:
# lib/my_app/application.ex
children = [
MyApp.Repo,
ArcanaWeb.TaskSupervisor,
Arcana.Embedder.Local,
Arcana.Graph.NERServing # Add this for GraphRAG
]Upgrading
Arcana owns its schema and tracks a version per install, so upgrading is one migration that delegates rather than DDL you write yourself:
defmodule MyApp.Repo.Migrations.UpgradeArcanaGraph do
use Ecto.Migration
def up, do: Arcana.Graph.Migration.up(dimensions: 384)
def down, do: Arcana.Graph.Migration.down()
endmix ecto.migrate
up/1 applies every version step between what your database has and the
target, and does nothing when you are already current. Check where an
install stands with:
Arcana.Graph.Migration.recorded_version(MyApp.Repo)
Arcana.Graph.Migration.current_version()Coming from a hand-written install
Installs predating versioned migrations have graph tables and no recorded version. Version 1 is written to converge them: it creates only what is absent and adds only what a later release introduced, so it never drops anything or re-runs DDL you already have. Two changes that shipped as standalone upgrade tasks are folded into it, and running v1 applies both:
- the unique index on
arcana_graph_entity_mentions (entity_id, chunk_id), without which theon_conflict: :nothingthe graph store writes with is a silent no-op and mention rows accumulate on every ingest. Duplicates already collected are deleted first, keeping the oldest row per pair arcana_graph_communities.summary_fingerprint, which records what each summary was generated from so a rebuild can tell an unchanged community from one whose graph moved on. Summaries written before it have no fingerprint, so they regenerate once on the next summarize run and settle
Installing into a Postgres schema
Pass :prefix to keep Arcana's tables in a schema of their own, and
create_schema: false if you manage the schema yourself:
def up, do: Arcana.Graph.Migration.up(dimensions: 384, prefix: "tenant_a")The graph tables foreign-key arcana_chunks and arcana_collections, so
the core migration has to go into the same schema. Installing the graph
into a prefix while the core tables sit in the default schema points those
references at tables that don't exist there, and the migration fails:
def up do
Arcana.Migration.up(dimensions: 384, prefix: "tenant_a")
Arcana.Graph.Migration.up(dimensions: 384, prefix: "tenant_a")
endConfiguration
GraphRAG is disabled by default. Enable it globally:
# config/config.exs
config :arcana,
graph: [
enabled: true,
community_levels: 1,
resolution: 1.0
]Community detection reads all of its knobs from this block: resolution,
min_size, community_levels (the hierarchy ceiling, default 1), plus objective,
iterations and seed. Pin a non-zero seed when you want community
membership to be reproducible across runs (0 lets the algorithm
randomize):
config :arcana,
graph: [
enabled: true,
community_detector: :leiden,
seed: 42,
objective: :cpm,
iterations: 2
]Options passed to Arcana.Maintenance.detect_communities/2 override the
config, and options carried by a community_detector: {Module, opts}
tuple override the generic knobs above.
Or enable per-call:
Arcana.ingest(text, repo: MyApp.Repo, graph: true)
Arcana.search(query, repo: MyApp.Repo, graph: true)Components
GraphRAG uses pluggable behaviours for extraction and community detection:
| Component | Default | Purpose |
|---|---|---|
| GraphExtractor | Arcana.Graph.GraphExtractor.LLM | Extract entities + relationships in one LLM call |
| EntityExtractor | Arcana.Graph.EntityExtractor.NER | Extract entities only (fallback) |
| RelationshipExtractor | Arcana.Graph.RelationshipExtractor.LLM | Find relationships (fallback) |
| CommunityDetector | Arcana.Graph.CommunityDetector.Leiden | Detect entity communities |
| CommunitySummarizer | Arcana.Graph.CommunitySummarizer.LLM | Generate community summaries |
Recommended: Use the combined GraphExtractor.LLM for efficiency (1 LLM call per chunk instead of 2).
Graph Storage
GraphRAG supports swappable storage backends for graph data:
| Backend | Purpose |
|---|---|
:ecto (default) | PostgreSQL persistence via Ecto |
:memory | In-memory storage for testing |
| Custom module | Your own implementation |
Configuration
# config/config.exs
# Use Ecto/PostgreSQL (default)
config :arcana, :graph_store, :ecto
# With options
config :arcana, :graph_store, {:ecto, repo: MyApp.Repo}
# Custom module
config :arcana, :graph_store, MyApp.CustomGraphStoreIn-Memory Backend (Testing)
The memory backend is useful for testing without database dependencies:
# Start a memory store
{:ok, pid} = Arcana.Graph.GraphStore.Memory.start_link([])
# Use in tests
Arcana.ingest(text, graph_store: {:memory, pid: pid})
Arcana.search(query, graph_store: {:memory, pid: pid})
# Or with a named process
{:ok, _} = Arcana.Graph.GraphStore.Memory.start_link(name: :test_graph)
Arcana.ingest(text, graph_store: {:memory, name: :test_graph})Custom Backend
Implement the Arcana.Graph.GraphStore behaviour. The full interface includes storage, query, deletion, and listing callbacks:
defmodule MyApp.Neo4jGraphStore do
@behaviour Arcana.Graph.GraphStore
# === Storage Callbacks ===
@impl true
def persist_entities(collection_id, entities, opts) do
# Store entities, return map of entity names to assigned IDs
{:ok, %{"Sam Altman" => "entity_123", "OpenAI" => "entity_456"}}
end
@impl true
def persist_relationships(relationships, entity_id_map, opts) do
# Store relationships between entities
:ok
end
@impl true
def persist_mentions(mentions, entity_id_map, opts) do
# Store entity-chunk mentions (links entities to source chunks)
:ok
end
@impl true
def persist_communities(collection_id, communities, opts) do
# Store community detection results
:ok
end
# === Query Callbacks ===
@impl true
def search(entity_names, collection_ids, opts) do
# Find chunks by entity names
[%{chunk_id: "chunk_123", score: 0.9}]
end
@impl true
def find_entities(collection_id, opts) do
# Return all entities in collection
[%{id: "entity_123", name: "Sam Altman", type: "person"}]
end
@impl true
def find_related_entities(entity_id, depth, opts) do
# Traverse graph to find related entities
[%{id: "entity_456", name: "OpenAI", type: "organization"}]
end
@impl true
def get_community_summaries(collection_id, opts) do
# Return community summaries
[%{id: "community_1", level: 0, summary: "AI research organizations..."}]
end
# === Detail Query Callbacks ===
@impl true
def get_entity(entity_id, opts) do
{:ok, %{id: entity_id, name: "Sam Altman", type: "person"}}
end
@impl true
def get_relationships(entity_id, opts) do
[%{id: "rel_1", source_id: entity_id, target_id: "entity_456", type: "LEADS"}]
end
@impl true
def get_relationship(relationship_id, opts) do
{:ok, %{id: relationship_id, source_id: "entity_123", target_id: "entity_456", type: "LEADS"}}
end
@impl true
def get_mentions(entity_id, opts) do
[%{entity_id: entity_id, chunk_id: "chunk_123", chunk_text: "..."}]
end
@impl true
def get_community(community_id, opts) do
{:ok, %{id: community_id, level: 0, summary: "..."}}
end
# === List Callbacks (for UI/Dashboard) ===
@impl true
def list_entities(opts) do
# Support :collection_id, :type, :search, :limit, :offset options
[%{id: "entity_123", name: "Sam Altman", type: "person", mention_count: 5}]
end
@impl true
def list_relationships(opts) do
# Support :collection_id, :type, :search, :strength, :limit, :offset options
[%{id: "rel_1", source_name: "Sam Altman", target_name: "OpenAI", type: "LEADS"}]
end
@impl true
def list_communities(opts) do
# Support :collection_id, :level, :search, :limit, :offset options
[%{id: "community_1", level: 0, entity_count: 10, summary: "..."}]
end
# === Deletion Callbacks ===
@impl true
def delete_by_chunks(chunk_ids, opts) do
# Delete mentions for chunks, cleanup orphaned entities
:ok
end
@impl true
def delete_by_collection(collection_id, opts) do
# Delete all graph data for a collection
:ok
end
endSee Arcana.Graph.GraphStore for the complete callback documentation.
Building a Graph
Combined Extraction (Recommended)
The combined GraphExtractor.LLM extracts entities and relationships in a single LLM call per chunk:
# config/runtime.exs - Enable combined extractor globally
config :arcana,
llm: {"openai:gpt-4o-mini", api_key: System.get_env("OPENAI_API_KEY")},
graph: [
enabled: true,
extractor: Arcana.Graph.GraphExtractor.LLM
]
# The LLM is automatically injected from the :arcana, :llm configOr use programmatically:
alias Arcana.Graph.GraphBuilder
# Build graph with combined extractor
{:ok, graph_data} = GraphBuilder.build(chunks,
extractor: {Arcana.Graph.GraphExtractor.LLM, llm: my_llm}
)
# Returns:
# %{
# entities: [%{name: "Sam Altman", type: "person", description: "CEO of OpenAI"}],
# relationships: [%{source: "Sam Altman", target: "OpenAI", type: "LEADS", strength: 9}],
# mentions: [%{entity_name: "Sam Altman", chunk_id: "chunk_123"}]
# }Separate Extractors (Fallback)
If extractor is not set, Arcana falls back to separate entity and relationship extractors:
# Build graph with separate extractors
{:ok, graph_data} = GraphBuilder.build(chunks,
entity_extractor: {Arcana.Graph.EntityExtractor.NER, []},
relationship_extractor: {Arcana.Graph.RelationshipExtractor.LLM, llm: my_llm}
)Entity Extraction
The default NER extractor uses Bumblebee for local entity recognition:
# Default NER extractor
extractor = {Arcana.Graph.EntityExtractor.NER, []}
{:ok, entities} = Arcana.Graph.EntityExtractor.extract(extractor, text)
# Returns entities like:
# [
# %{name: "Sam Altman", type: :person},
# %{name: "OpenAI", type: :organization}
# ]Relationship Extraction
The LLM extractor uses an LLM to identify semantic relationships:
# LLM-based relationship extraction
extractor = {Arcana.Graph.RelationshipExtractor.LLM, llm: &MyApp.llm/3}
{:ok, relationships} = Arcana.Graph.RelationshipExtractor.extract(extractor, text, entities)
# Returns relationships like:
# [
# %{source: "Sam Altman", target: "OpenAI", type: "LEADS", strength: 9}
# ]Community Detection
The Leiden algorithm detects clusters of related entities:
# Leiden community detection
detector = {Arcana.Graph.CommunityDetector.Leiden, resolution: 1.0}
{:ok, communities} = Arcana.Graph.CommunityDetector.detect(detector, entities, relationships)
# Returns communities with hierarchy:
# [
# %{level: 0, entity_ids: ["entity1", "entity2"]},
# %{level: 1, entity_ids: ["entity1", "entity2", "entity3"]}
# ]Querying the Graph
Find Entities
# Find entities by name
entities = Graph.find_entities(graph, "OpenAI")
# With fuzzy matching
entities = Graph.find_entities(graph, "Open AI", fuzzy: true)Traverse Relationships
# Get connected entities
connected = Graph.traverse(graph, entity_id, depth: 2)Graph Search
# Search graph for relevant chunks
entities = [%{name: "OpenAI", type: :organization}]
results = Graph.search(graph, entities, depth: 2)Fusion Search
Combine vector and graph search with Reciprocal Rank Fusion:
# Run vector search
{:ok, vector_results} = Arcana.search(query, repo: MyApp.Repo)
# Extract entities from query
{:ok, entities} = Arcana.Graph.EntityExtractor.NER.extract(query, [])
# Combine with graph search
results = Graph.fusion_search(graph, entities, vector_results,
depth: 2,
limit: 10,
k: 60 # RRF constant
)Community Summaries
Get high-level context about entity clusters:
# Get all summaries at a specific level
summaries = Graph.community_summaries(graph, level: 0)
# Get summaries containing a specific entity
summaries = Graph.community_summaries(graph, entity_id: "entity123")Custom Implementations
All components support custom implementations via behaviours.
Custom GraphExtractor (Combined)
defmodule MyApp.CustomGraphExtractor do
@behaviour Arcana.Graph.GraphExtractor
@impl true
def extract(text, opts) do
# Your extraction logic - return both entities and relationships
entities = extract_entities(text, opts)
relationships = extract_relationships(text, entities, opts)
{:ok, %{entities: entities, relationships: relationships}}
end
end
# Configure globally
config :arcana, :graph,
extractor: MyApp.CustomGraphExtractorCustom Entity Extractor
defmodule MyApp.SpacyExtractor do
@behaviour Arcana.Graph.EntityExtractor
@impl true
def extract(text, opts) do
endpoint = Keyword.get(opts, :endpoint)
# Call your spaCy API...
{:ok, entities}
end
end
# Configure globally
config :arcana, :graph,
entity_extractor: {MyApp.SpacyExtractor, endpoint: "http://localhost:5000"}Custom Relationship Extractor
defmodule MyApp.PatternExtractor do
@behaviour Arcana.Graph.RelationshipExtractor
@impl true
def extract(text, entities, opts) do
patterns = Keyword.get(opts, :patterns, [])
# Pattern-based extraction...
{:ok, relationships}
end
end
# Configure globally
config :arcana, :graph,
relationship_extractor: {MyApp.PatternExtractor, patterns: [...]}Custom Community Detector
defmodule MyApp.LouvainDetector do
@behaviour Arcana.Graph.CommunityDetector
@impl true
def detect(entities, relationships, opts) do
resolution = Keyword.get(opts, :resolution, 0.5)
# Louvain algorithm...
{:ok, communities}
end
end
# Configure globally
config :arcana, :graph,
community_detector: {MyApp.LouvainDetector, resolution: 0.5}Custom Community Summarizer
defmodule MyApp.ExtractiveSum do
@behaviour Arcana.Graph.CommunitySummarizer
@impl true
def summarize(entities, relationships, opts) do
max_sentences = Keyword.get(opts, :max_sentences, 3)
# Extractive summarization from entity descriptions...
{:ok, summary}
end
end
# Configure globally
config :arcana, :graph,
community_summarizer: {MyApp.ExtractiveSum, max_sentences: 3}
# Or disable summarization entirely
config :arcana, :graph,
community_summarizer: nilInline Functions
All extractors also support inline functions:
# Inline combined extractor (recommended)
extractor = fn text, _opts ->
{:ok, %{
entities: [%{name: "Example", type: :concept}],
relationships: [%{source: "A", target: "B", type: "RELATES_TO"}]
}}
end
GraphBuilder.build(chunks, extractor: extractor)
# Or use separate inline extractors
entity_extractor = fn text, _opts ->
{:ok, [%{name: "Example", type: :concept}]}
end
relationship_extractor = fn text, entities, _opts ->
{:ok, [%{source: "A", target: "B", type: "RELATES_TO"}]}
end
GraphBuilder.build(chunks,
entity_extractor: entity_extractor,
relationship_extractor: relationship_extractor
)
# Inline community summarizer
summarizer = fn entities, relationships, _opts ->
{:ok, "Community with #{length(entities)} entities"}
end
CommunitySummarizer.summarize(entities, relationships, community_summarizer: summarizer)Telemetry
GraphRAG emits telemetry events for observability.
Graph Building Events
[:arcana, :graph, :build, :start | :stop | :exception]- Full graph build[:arcana, :graph, :ner, :start | :stop | :exception]- Named entity recognition[:arcana, :graph, :relationship_extraction, :start | :stop | :exception]- Relationship extraction[:arcana, :graph, :community_detection, :start | :stop | :exception]- Community detection[:arcana, :graph, :community_summary, :start | :stop | :exception]- Community summarization
Graph Search Events
[:arcana, :graph, :search, :start | :stop | :exception]- Graph-enhanced search
Graph Store Events
These events are emitted by the storage layer:
[:arcana, :graph_store, :persist_entities, :start | :stop | :exception][:arcana, :graph_store, :persist_relationships, :start | :stop | :exception][:arcana, :graph_store, :persist_mentions, :start | :stop | :exception][:arcana, :graph_store, :search, :start | :stop | :exception][:arcana, :graph_store, :delete_by_chunks, :start | :stop | :exception][:arcana, :graph_store, :delete_by_collection, :start | :stop | :exception]
Example Handler
:telemetry.attach(
"graph-metrics",
[:arcana, :graph, :build, :stop],
fn _event, measurements, metadata, _config ->
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.info("Built graph: #{metadata.entity_count} entities, #{metadata.relationship_count} relationships in #{duration_ms}ms")
end,
nil
)See the Telemetry Guide for more details on monitoring and metrics integration.
Maintenance Tasks
GraphRAG provides mix tasks for managing the knowledge graph.
Rebuild Graph
Re-extract entities and relationships from all chunks:
# Rebuild all collections
mix arcana.graph.rebuild
# Rebuild specific collection
mix arcana.graph.rebuild --collection my-docs
# Resume interrupted rebuild
mix arcana.graph.rebuild --resume
Use this when:
- You've changed the graph extractor configuration
- You want to regenerate entity/relationship data
- You've enabled relationship extraction after initial ingest
Detect Communities
Run Leiden community detection on the graph:
# Detect communities for all collections
mix arcana.graph.detect_communities
# Specific collection
mix arcana.graph.detect_communities --collection my-docs
# Custom resolution (higher = smaller communities)
mix arcana.graph.detect_communities --resolution 1.5
# Multiple hierarchy levels
mix arcana.graph.detect_communities --max-level 3
Summarize Communities
Generate LLM summaries for detected communities:
# Summarize dirty communities (those needing regeneration)
mix arcana.graph.summarize_communities
# Force regenerate all summaries
mix arcana.graph.summarize_communities --force
# Specific collection
mix arcana.graph.summarize_communities --collection my-docs
# Parallel summarization (faster)
mix arcana.graph.summarize_communities --concurrency 4
# Quiet mode (less output)
mix arcana.graph.summarize_communities --quiet
# Every hierarchy level, not just the ones ask reads
mix arcana.graph.summarize_communities --levels all
Summarization only covers the levels ask reads, which is
community_summary_level (default 0). Detection can generate a deeper
hierarchy with community_levels, but summarizing a level nothing queries
costs one LLM call per community for nothing. The key accepts an integer, a
list, a range or :all, and both sides read it:
config :arcana,
graph: [
community_levels: 3, # generate three levels
community_summary_level: 0..1 # summarize and read the first two
]Requires an LLM to be configured. All standard :llm config formats are supported:
# String format (simplest, uses req_llm config for API key)
config :arcana, :llm, "openai:gpt-4o-mini"
# Tuple with options (explicit API key)
config :arcana, :llm, {"openai:gpt-4o-mini", api_key: "..."}
# Function (full control)
config :arcana, :llm, fn prompt, context, opts ->
{:ok, MyApp.LLM.complete(prompt)}
endSee the LLM Integration guide for more details.
Typical Workflow
After ingesting new documents:
# 1. Detect communities in the graph
mix arcana.graph.detect_communities
# 2. Generate summaries for communities
mix arcana.graph.summarize_communities
To refresh everything:
# 1. Rebuild the graph (re-extract entities/relationships)
mix arcana.graph.rebuild
# 2. Re-detect communities
mix arcana.graph.detect_communities
# 3. Regenerate all summaries
mix arcana.graph.summarize_communities --force