Threat Intelligence Graph RAG with Neo4j and Ollama

By · · AI Engineering

Cybersecurity analysts spend a lot of time navigating the MITRE ATT&CK framework — mapping threat actors to techniques, understanding kill chains, and identifying mitigations. The framework is powerful but dense. What if you could simply ask "What techniques does APT29 use for initial access, and how do I defend against them?" and get a precise, sourced answer in seconds?

That is exactly what I built. Threat Intelligence Graph RAG is a fully containerized system that combines a Neo4j knowledge graph with local LLMs (via Ollama) to enable natural language querying of cybersecurity threat intelligence. No data leaves your machine, no cloud API keys required — everything runs locally with Docker.

Threat Intel Graph RAG - Knowledge Graph Map

Why graph RAG for threat intelligence

Traditional RAG systems work by chunking documents, embedding them into vectors, and retrieving the most similar chunks. This works well for unstructured text, but threat intelligence data is inherently relational. A threat actor uses techniques, techniques belong to tactics, techniques are mitigated by specific defenses, and malware employs techniques. These relationships are first-class information that vector search alone cannot capture.

Graph RAG solves this by storing data in a knowledge graph where relationships are explicit, queryable, and traversable. Instead of hoping that a vector similarity search finds the right paragraph, we can walk the graph from a threat actor to their techniques, through the kill chain phases, and out to the mitigations — all with precision.

The advantages

Architecture

The system runs as five Docker services orchestrated with Docker Compose:

┌──────────────────────────────────────────────┐
│           Docker Compose Network             │
│                                              │
│  Frontend (Nginx:8501) ──────┐               │
│                               │               │
│  Backend (FastAPI:8000) ◄────┤               │
│       ├─ Query Router         │               │
│       ├─ Graph Router         │               │
│       └─ RAG Pipeline         │               │
│                               │               │
│  Neo4j (Bolt:7687) ◄─────────┤               │
│       ├─ ThreatActor nodes    │               │
│       ├─ Technique nodes      │               │
│       └─ MITRE relationships  │               │
│                               │               │
│  Ollama (11434) ◄─────────────┘               │
│       ├─ mistral:7b                           │
│       └─ nomic-embed-text                     │
│                                              │
│  Data Ingestion (init job)                   │
│       └─ STIX 2.1 parser → Neo4j loader      │
└──────────────────────────────────────────────┘

Tech stack

Layer Technology Purpose
Graph Database Neo4j 5.15 Community + APOC Store and query threat intelligence relationships
LLM Ollama with Mistral 7B Entity extraction, Cypher generation, response synthesis
Embeddings nomic-embed-text Text vectorization (available for future semantic search)
Backend FastAPI Async REST API for queries and graph operations
Frontend Vanilla JS + vis-network + Chart.js Interactive graph visualization and chat UI
Data Source MITRE ATT&CK STIX 2.1 Authoritative threat intelligence knowledge base
Deployment Docker Compose Single-command deployment with health checks

The knowledge graph

The graph schema models the MITRE ATT&CK framework as interconnected nodes and relationships:

Node types

ThreatActor  {id, name, description, aliases[], country}
Technique    {id, name, description, platforms[], detection}
Tactic       {id, name, description, shortname}
Malware      {id, name, description, platforms[]}
Tool         {id, name, description}
Mitigation   {id, name, description}

Relationships

(:ThreatActor)-[:USES]->(:Technique|:Malware|:Tool)
(:Technique)-[:BELONGS_TO]->(:Tactic)
(:Technique)-[:MITIGATED_BY]->(:Mitigation)
(:Malware|:Tool)-[:EMPLOYS]->(:Technique)
(:Technique)-[:SUBTECHNIQUE_OF]->(:Technique)

This schema allows powerful traversals. For example, to find all mitigations for techniques used by a specific threat actor, you simply walk: ThreatActor → USES → Technique → MITIGATED_BY → Mitigation. In Cypher, that is a single query.

Data ingestion pipeline

The system ingests data from the official MITRE ATT&CK STIX 2.1 JSON feed. The ingestion runs as an init container in Docker Compose.

ATTACK_STIX_URL = (
    "https://raw.githubusercontent.com/mitre-attack/"
    "attack-stix-data/master/enterprise-attack/"
    "enterprise-attack.json"
)

def parse_stix_bundle(bundle: dict) -> dict:
    nodes, relationships = {}, []

    # First pass: extract nodes (threat actors, techniques, etc.)
    for obj in bundle["objects"]:
        obj_type = obj.get("type")
        if obj_type in ATTACK_TYPE_MAP:
            node = _parse_node(obj, ATTACK_TYPE_MAP[obj_type])
            nodes[obj["id"]] = node

    # Second pass: extract explicit relationships
    for obj in bundle["objects"]:
        if obj["type"] == "relationship":
            rel = _parse_relationship(obj, nodes)
            relationships.append(rel)

    # Third pass: extract kill chain phase mappings
    for obj in bundle["objects"]:
        if obj["type"] == "attack-pattern":
            tactic_rels = _extract_tactic_relationships(obj, nodes)
            relationships.extend(tactic_rels)

    return {"nodes": nodes, "relationships": relationships}

The loader uses MERGE operations instead of CREATE to ensure idempotent ingestion — you can re-run the ingestion without creating duplicates:

def load_node(self, node: dict, node_type: str):
    query = f"MERGE (n:{node_type} {{id: $id}}) SET n += $props"
    self.session.run(query, {"id": node["id"], "props": properties})

After loading nodes and explicit relationships, the pipeline derives additional relationships. For example, if a threat actor uses both a piece of malware and a technique, we infer that the malware employs that technique:

MATCH (a:ThreatActor)-[:USES]->(m:Malware)
MATCH (a)-[:USES]->(t:Technique)
MERGE (m)-[:EMPLOYS]->(t)

The RAG pipeline

The central piece is a four-stage RAG pipeline that transforms natural language questions into graph-backed answers.

Stage 1: Entity extraction

The LLM extracts structured entities and intent from the user's natural language query:

def extract_entities(self, query: str) -> dict:
    system_prompt = """You are a cybersecurity threat intelligence analyst.
Extract entities and intent from the user's query.
Return JSON with:
- intent: one of [get_actor_info, get_technique_info,
  get_mitigations, get_attack_path, search, compare, list]
- entities: {
    actor_names: [],
    technique_ids: [],
    technique_names: [],
    malware_names: [],
    keywords: []
  }
Only return valid JSON, no other text."""

    response = self.generate(
        prompt=query,
        system_prompt=system_prompt,
        temperature=0.1,
        max_tokens=500
    )
    return json.loads(response)

The low temperature (0.1) ensures consistent, deterministic entity extraction.

Stage 2: Intent-based graph retrieval

Instead of always generating dynamic Cypher (which LLMs can get wrong), the pipeline routes to pre-built queries for known intents:

async def process_query(self, query: str) -> dict:
    entities = self.ollama.extract_entities(query)
    intent = entities.get("intent", "search")

    if intent == "get_actor_info":
        context = self.neo4j.get_actor_techniques(
            entities["entities"]["actor_names"][0]
        )
    elif intent == "get_attack_path":
        context = self.neo4j.get_attack_path(
            entities["entities"]["actor_names"][0]
        )
    elif intent == "get_mitigations":
        context = self.neo4j.get_technique_mitigations(
            entities["entities"]["technique_ids"][0]
        )
    else:
        # Fallback: generate Cypher dynamically
        cypher = self.ollama.generate_cypher(query, entities)
        context = self.neo4j.execute_query(cypher)

    return await self._generate_response(query, context)

This hybrid approach is critical. Pre-built queries for common intents are reliable and fast. Dynamic Cypher generation is reserved for complex or unusual queries where the LLM needs flexibility.

Stage 3: Context building

Graph results are formatted into structured markdown context that the LLM can reason over:

Threat actor 'APT29' uses these techniques:
- Spear Phishing Attachment (T1566.001)
- Supply Chain Compromise (T1195)
- Valid Accounts (T1078)

Attack path (ordered by kill chain phase):
1. Reconnaissance: Active Scanning (T1595)
2. Initial Access: Phishing (T1566)
3. Execution: Command and Scripting Interpreter (T1059)
4. Persistence: Boot or Logon Autostart Execution (T1547)
...

Available mitigations for T1566:
- User Training (M1017)
- Email Filtering (M1049)

Stage 4: Response generation

The LLM synthesizes the graph context into a human-readable answer:

async def _generate_response(self, query: str, context: str) -> dict:
    system_prompt = """You are a cybersecurity threat intelligence analyst.
Use the provided context from the threat intelligence knowledge graph.
Be concise, accurate, and cite specific threat actors, techniques,
or mitigations. Format response with clear structure using markdown."""

    answer = self.ollama.generate(
        prompt=f"Context:\n{context}\n\nQuestion: {query}",
        system_prompt=system_prompt,
        temperature=0.3,
        max_tokens=1500
    )

    return {"answer": answer, "sources": sources, "reasoning": reasoning}

Kill chain ordering

One of the most valuable features is the ability to see a threat actor's techniques ordered by kill chain phase. The Cypher query uses an explicit ordering based on the MITRE ATT&CK tactical sequence:

MATCH (a:ThreatActor)-[:USES]->(t:Technique)-[:BELONGS_TO]->(tac:Tactic)
WHERE toLower(a.name) CONTAINS toLower($actor_name)
RETURN t.id, t.name, tac.name, tac.shortname
ORDER BY
    CASE tac.shortname
        WHEN 'reconnaissance' THEN 1
        WHEN 'resource-development' THEN 2
        WHEN 'initial-access' THEN 3
        WHEN 'execution' THEN 4
        WHEN 'persistence' THEN 5
        WHEN 'privilege-escalation' THEN 6
        WHEN 'defense-evasion' THEN 7
        WHEN 'credential-access' THEN 8
        WHEN 'discovery' THEN 9
        WHEN 'lateral-movement' THEN 10
        WHEN 'collection' THEN 11
        WHEN 'command-and-control' THEN 12
        WHEN 'exfiltration' THEN 13
        WHEN 'impact' THEN 14
        ELSE 99
    END

This transforms raw graph data into a narrative: here is how this actor operates, from the first reconnaissance step through to the final impact.

API design

The backend exposes a clean REST API:

# Natural language query
@app.post("/query")
async def query(request: QueryRequest) -> QueryResponse:
    result = await rag_pipeline.process_query(request.query)
    return QueryResponse(**result)

# Graph exploration endpoints
@app.get("/graph/actors")
@app.get("/graph/techniques")
@app.get("/graph/actors/{name}/techniques")
@app.get("/graph/actors/{name}/attack-path")
@app.get("/graph/techniques/{id}/mitigations")
@app.get("/graph/search")
@app.get("/graph/visualize")
@app.get("/graph/stats")

# Health check
@app.get("/health")
async def health_check() -> HealthResponse:
    neo4j_ok = neo4j_service.health_check()
    ollama_ok = ollama_service.health_check()
    status = "healthy" if neo4j_ok and ollama_ok else "degraded"
    return HealthResponse(
        status=status, neo4j=neo4j_ok, ollama=ollama_ok
    )

The separation between /query (RAG-powered natural language) and /graph/* (direct graph access) lets the frontend offer both conversational and structured exploration modes.

The frontend

The frontend is a single-page application with four views:

The query interface includes a multi-stage loading overlay that shows progress through the RAG pipeline: Analyze, Extract, Query, Generate.

Deployment

The entire system runs with a single command:

make up

The Docker Compose file orchestrates all five services with health checks and dependency ordering:

services:
  neo4j:
    image: neo4j:5.15-community
    volumes:
      - neo4j_data:/data
    healthcheck:
      test: ["CMD", "neo4j", "status"]

  ollama:
    image: ollama/ollama:latest
    deploy:
      resources:
        limits:
          memory: 8G

  backend:
    build: ./backend
    depends_on:
      neo4j:
        condition: service_healthy
      ollama:
        condition: service_healthy

  frontend:
    build: ./frontend
    ports:
      - "8501:80"

  data-ingestion:
    build: ./data-ingestion
    depends_on:
      neo4j:
        condition: service_healthy

Resource requirements are modest for what the system delivers: roughly 16GB RAM total, with 8GB allocated to Ollama for the Mistral 7B model. No GPU required — everything runs on CPU.

Key design decisions

Why Ollama over cloud APIs

Privacy matters when dealing with threat intelligence. By running Mistral 7B locally through Ollama, no query data, no threat actor names, no organizational context ever leaves the machine. For security teams evaluating threats to their infrastructure, this is non-negotiable.

Why intent routing over pure Cypher generation

LLMs are not reliable Cypher generators. They hallucinate property names, invent relationship types, and produce syntactically invalid queries. By routing known intents (actor info, mitigations, attack paths) to hand-crafted, tested Cypher queries, we get reliability where it matters most. Dynamic Cypher generation is only used as a fallback for truly novel queries.

Why Neo4j over a vector database

Vector databases excel at "find me things similar to X" queries. But threat intelligence questions are mostly about relationships. "What mitigations cover techniques used by APT29 for credential access?" requires traversing specific graph paths, not finding semantically similar text chunks.

Singleton services with retry logic

Both Neo4j and Ollama connections use the singleton pattern with Tenacity-based retry logic (3 attempts, exponential backoff). This handles the reality of containerized services: Neo4j might take 30 seconds to start, and Ollama needs time to load models into memory.

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
def execute_query(self, cypher: str, params: dict = None):
    with self.driver.session() as session:
        result = session.run(cypher, params or {})
        return [record.data() for record in result]

What is next

There are several directions this project can grow:

Conclusion

Graph RAG is a natural fit for threat intelligence. The MITRE ATT&CK framework is already a graph — threat actors connected to techniques connected to tactics connected to mitigations. Storing this data in Neo4j and querying it with natural language through a local LLM creates a private, practical tool for security analysts.

The combination of intent-based routing for reliability, dynamic Cypher generation for flexibility, and local LLM inference for privacy makes this a practical system, not just a demo. If you work in cybersecurity and spend time navigating the ATT&CK framework, I encourage you to give it a try.

The full source code is available on my GitHub. Clone the repo, run make up, and start asking your threat intelligence questions in plain English.