GraphRAG: Build a Knowledge Graph from a Wiki with an LLM and Answer Multi-Hop Questions
Hands-on lab · IDE in your browser

GraphRAG: Build a Knowledge Graph from a Wiki with an LLM and Answer Multi-Hop Questions

Turn an engineering wiki into a knowledge graph and answer the questions vector RAG cannot.

Time
60 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit60 min · 5 stepsSession running
4 / 5 steps passingGraph against vector RAG · step 5 of 5
graphrag.py▶ Run✓ Check
# ---------- Step 5: which questions each method answers ---------- def answer_f1(pred, gold):    """F1 between two sets of ids: 1.0 if both are empty, 0.0 if only one is (None counts as empty)."""      def evaluate(answers, questions):    """{question type: mean answer_f1, ..., "all": mean over every question}; answers[i] answers questions[i]."""        def hybrid(graph_ids, vector_ids):    """The graph's answer when it has one (not None and not empty), otherwise the vector answer.""" 
TerminalOutput

The job

Larkspur Pay's engineers ask the wiki bot things like "what breaks if the shared Redis goes down?" and get half an answer. You build a graph of every service, datastore and team from the wiki's pages and measure which questions it answers better than plain retrieval.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Extract triples

    Larkspur Pay's engineering wiki (wiki.json) has a page per service, datastore and team, plus postmortems.

    You writeextraction_prompt()parse_triples()extract_all()
  2. 2

    One node per thing

    The model writes names the way pages do: "the shared Redis", "Redis", "Redis Cache".

    You writenormalise()build_lookup()resolve()build_graph()precision_recall()
  3. 3

    Walk the graph

    A question becomes a plan: where to start, which edges to follow, and what to keep.

    You writehop()run_plan()
  4. 4

    Questions become graph queries

    The model does not answer questions here.

    You writeplan_prompt()parse_plan()graph_answer()
  5. 5

    Graph against vector RAG

    H.vector_answer() is plain RAG: the 4 pages closest to the question go to the same model, which names the answer.

    You writeanswer_f1()evaluate()hybrid()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Extract triples

Larkspur Pay's engineering wiki (wiki.json) has a page per service, datastore and team, plus postmortems. Questions like "which public services break if the shared Redis goes down?" have no page of their own: the answer is spread over a dozen pages, linked by dependencies. GraphRAG turns the pages into a graph first, then walks it.

A graph is built from triples: (subject, relation, object), such as ("Checkout API", "depends_on", "Orders DB"). Here there are three relations, listed in RELATIONS.

Do this

1. Write extraction_prompt(page) in graphrag.py. It should contain:

  • the relations and what each means;
  • rules: facts true now only (skip what the page says was removed, moved or is not the case); names as the page writes them;
  • the reply format, a JSON list of {"subject", "relation", "object"}, with an example;
  • the page.

2. Write parse_triples(reply): find the JSON list in the reply (models add text and fences), load it, and keep items that are dicts with three non-empty strings. Strip the names; normalise the relation (strip, lower case, spaces to _) and keep it only if it is in RELATIONS. Return [] if the JSON cannot be read.

3. Write extract_all(pages, workers): send each page's prompt with H.ask_model(), workers at a time (ThreadPoolExecutor), and return {page id: triples}.

4. Run. It extracts all 37 pages and saves them to extracted.json. Two of the pages it prints contain a trap.

graphrag.py, the file you edit139 lines
"""A knowledge graph of Larkspur Pay's systems, built from the engineering wiki. You write the functions marked
TODO, one step at a time."""
import difflib
import json
import re
from concurrent.futures import ThreadPoolExecutor

import harness as H

RELATIONS = {
    "depends_on": "X needs Y to work: X calls Y, reads from it or writes to it (X is a service or datastore)",
    "owned_by": "X (a service or datastore) is owned and run by team Y",
    "led_by": "team X is led by person Y",
}


# ---------- Step 1: extract triples ----------

def extraction_prompt(page):
    """The prompt that asks the model for the page's (subject, relation, object) triples as JSON."""
    # TODO (Step 1): list the RELATIONS, the rules for what counts as a fact, the JSON format
    # with an example, and the page.
    raise NotImplementedError("Step 1: write extraction_prompt()")


def parse_triples(reply):
    """[(subject, relation, object), ...] from the model's reply: the JSON list inside it, keeping only well-formed
    items whose relation is in RELATIONS. Anything unreadable gives []."""
    # TODO (Step 1): find the JSON list in the reply (re.search(r"\[.*\]", reply, re.S)), json.loads it,
    # keep dicts with non-empty string subject/relation/object whose normalised relation is in RELATIONS.
    raise NotImplementedError("Step 1: write parse_triples()")


def extract_all(pages, workers=8):
    """{page id: parse_triples(the model's reply to extraction_prompt(text))}, asking about up to `workers` pages at
    a time."""
    # TODO (Step 1): with ThreadPoolExecutor(workers), H.ask_model(extraction_prompt(text)) per page, then
    # parse_triples() each reply.
    raise NotImplementedError("Step 1: write extract_all()")


# ---------- Step 2: one node per thing ----------

TYPES = {"depends_on": ({"service", "datastore"}, {"service", "datastore"}),
         "owned_by": ({"service", "datastore"}, {"team"}),
         "led_by": ({"team"}, {"person"})}


class Graph:
    def __init__(self):
        self.edges = set()      # {(subject id, relation, object id)}
        self.sources = {}       # {edge: [page ids that state it]}
        self.dropped = []       # [(page id, (subject, relation, object) as extracted, reason)]

    def add(self, edge, page):
        self.edges.add(edge)
        self.sources.setdefault(edge, [])
        if page not in self.sources[edge]:
            self.sources[edge].append(page)


def normalise(name):
    """Lower case; "-", "_" and "." become spaces; no leading "the "; no trailing " service" or " team"; single spaces."""
    raise NotImplementedError("normalise() arrives in Step 2")


def build_lookup(catalogue):
    """{normalised id, name or alias: catalogue id} for every entity."""
    raise NotImplementedError("build_lookup() arrives in Step 2")


def resolve(name, lookup):
    """The catalogue id for a name: exact match after normalise(), else the closest key by difflib with cutoff 0.85,
    else None."""
    raise NotImplementedError("resolve() arrives in Step 2")


def build_graph(extracted, catalogue):
    """A Graph of resolved triples. A triple is dropped (with a reason) if a name does not resolve, or if the
    types do not fit TYPES (a team cannot depend on anything, for example)."""
    raise NotImplementedError("build_graph() arrives in Step 2")


def precision_recall(edges, labelled):
    """(precision, recall) of a set of edges against the hand-labelled triples."""
    raise NotImplementedError("precision_recall() arrives in Step 2")


# ---------- Step 3: walk the graph ----------

def hop(graph, nodes, step):
    """The nodes one step away: "owned_by" follows edges forwards, "~owned_by" backwards, and a trailing "*" repeats
    the step until nothing new is reached (the result never includes the nodes you started from)."""
    raise NotImplementedError("hop() arrives in Step 3")


def run_plan(graph, catalogue, plan):
    """Sorted ids: start at plan["start"], apply each hop in plan["hops"], then keep only nodes matching every
    plan["where"] condition ("type", "public", or "owned_by": a team id)."""
    raise NotImplementedError("run_plan() arrives in Step 3")


# ---------- Step 4: questions become graph queries ----------

HOP_RE = re.compile(r"^~?(depends_on|owned_by|led_by)\*?$")


def plan_prompt(question, catalogue):
    """The prompt that turns a question into a plan: {"start": id, "hops": [...], "where": {...}} as JSON."""
    raise NotImplementedError("plan_prompt() arrives in Step 4")


def parse_plan(reply, catalogue):
    """The plan in the reply as a dict, or None unless: "start" is a catalogue id, "hops" is a non-empty list of
    hops matching HOP_RE, and "where" (if present) is a dict whose keys are "type", "public" or "owned_by"."""
    raise NotImplementedError("parse_plan() arrives in Step 4")


def graph_answer(graph, catalogue, question):
    """(ids, plan): run_plan() on the plan the model writes for the question, or (None, None) if the plan is
    invalid."""
    raise NotImplementedError("graph_answer() arrives in Step 4")


# ---------- Step 5: which questions each method answers ----------

def answer_f1(pred, gold):
    """F1 between two sets of ids: 1.0 if both are empty, 0.0 if only one is (None counts as empty)."""
    raise NotImplementedError("answer_f1() arrives in Step 5")


def evaluate(answers, questions):
    """{question type: mean answer_f1, ..., "all": mean over every question}; answers[i] answers questions[i]."""
    raise NotImplementedError("evaluate() arrives in Step 5")


def hybrid(graph_ids, vector_ids):
    """The graph's answer when it has one (not None and not empty), otherwise the vector answer."""
    raise NotImplementedError("hybrid() arrives in Step 5")
Provided for you:catalogue.jsonharness.pylabelled.jsonquestions.jsontry_it.pywiki.json

Frequently asked questions

What is GraphRAG?

Retrieval-augmented generation over a knowledge graph. An LLM extracts entities and relations from documents into a graph, and questions are answered by traversing it, which handles multi-hop and aggregate questions that similarity search misses.

Why does a knowledge graph need entity resolution?

Documents name the same thing in many ways. Unless every spelling maps to one node, the graph splits into disconnected copies and paths through them break.

When is vector RAG enough?

When one passage contains the answer, such as who owns a service. Questions about chains of relations, such as everything affected by an outage, need the graph.

What GraphRAG adds to RAG

Vector RAG retrieves the passages closest to a question, which works when one passage holds the answer. Questions that follow a chain of relations across many documents need a graph. You extract (subject, relation, object) triples with an LLM, resolve entity names against a catalogue, measure precision and recall against hand labels, run multi-hop and transitive graph queries, translate questions into validated query plans and compare GraphRAG, vector RAG and a hybrid by question type.