> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cognee.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Org Chart from JSON Exports

> Turn two flat JSON exports of companies and people into a connected org-chart graph, with deterministic node IDs doing the deduplication

export const orgChartGraph = {
  "nodes": [{
    "id": "pers-6",
    "name": "Emma Thompson",
    "type": "Person"
  }, {
    "id": "pers-2",
    "name": "Bob Williams",
    "type": "Person"
  }, {
    "id": "pers-1",
    "name": "Alice Johnson",
    "type": "Person"
  }, {
    "id": "depa-6",
    "name": "HR",
    "type": "Department"
  }, {
    "id": "depa-11",
    "name": "Sales",
    "type": "Department"
  }, {
    "id": "depa-8",
    "name": "Manufacturing",
    "type": "Department"
  }, {
    "id": "comp-5",
    "name": "TechNova Inc.",
    "type": "Company"
  }, {
    "id": "depa-5",
    "name": "Engineering",
    "type": "Department"
  }, {
    "id": "depa-9",
    "name": "Marketing",
    "type": "Department"
  }, {
    "id": "pers-7",
    "name": "Jane Smith",
    "type": "Person"
  }, {
    "id": "depa-3",
    "name": "Administration",
    "type": "Department"
  }, {
    "id": "depa-7",
    "name": "Healthcare",
    "type": "Department"
  }, {
    "id": "pers-8",
    "name": "John Doe",
    "type": "Person"
  }, {
    "id": "depa-10",
    "name": "Research & Development",
    "type": "Department"
  }, {
    "id": "comp-6",
    "name": "Company",
    "type": "CompanyType"
  }, {
    "id": "comp-2",
    "name": "MediCare Plus",
    "type": "Company"
  }, {
    "id": "depa-2",
    "name": "Accounting",
    "type": "Department"
  }, {
    "id": "comp-3",
    "name": "NextGen Robotics",
    "type": "Company"
  }, {
    "id": "comp-1",
    "name": "GreenFuture Solutions",
    "type": "Company"
  }, {
    "id": "pers-10",
    "name": "Sophia White",
    "type": "Person"
  }, {
    "id": "pers-5",
    "name": "Emily Davis",
    "type": "Person"
  }, {
    "id": "comp-4",
    "name": "Skyline Financials",
    "type": "Company"
  }, {
    "id": "depa-1",
    "name": "AI Development",
    "type": "Department"
  }, {
    "id": "depa-4",
    "name": "Customer Support",
    "type": "Department"
  }, {
    "id": "pers-9",
    "name": "Michael Brown",
    "type": "Person"
  }, {
    "id": "pers-4",
    "name": "David Wilson",
    "type": "Person"
  }, {
    "id": "pers-3",
    "name": "Chris Martin",
    "type": "Person"
  }],
  "links": [{
    "source": "depa-6",
    "target": "pers-10",
    "relation": "employees"
  }, {
    "source": "depa-11",
    "target": "pers-1",
    "relation": "employees"
  }, {
    "source": "depa-8",
    "target": "pers-3",
    "relation": "employees"
  }, {
    "source": "comp-5",
    "target": "depa-5",
    "relation": "departments"
  }, {
    "source": "comp-5",
    "target": "depa-9",
    "relation": "departments"
  }, {
    "source": "comp-5",
    "target": "comp-6",
    "relation": "is_type"
  }, {
    "source": "depa-5",
    "target": "pers-8",
    "relation": "employees"
  }, {
    "source": "depa-9",
    "target": "pers-7",
    "relation": "employees"
  }, {
    "source": "depa-3",
    "target": "pers-4",
    "relation": "employees"
  }, {
    "source": "depa-7",
    "target": "pers-5",
    "relation": "employees"
  }, {
    "source": "comp-2",
    "target": "comp-6",
    "relation": "is_type"
  }, {
    "source": "comp-2",
    "target": "depa-7",
    "relation": "departments"
  }, {
    "source": "comp-2",
    "target": "depa-3",
    "relation": "departments"
  }, {
    "source": "depa-2",
    "target": "pers-9",
    "relation": "employees"
  }, {
    "source": "comp-3",
    "target": "comp-6",
    "relation": "is_type"
  }, {
    "source": "comp-3",
    "target": "depa-1",
    "relation": "departments"
  }, {
    "source": "comp-3",
    "target": "depa-8",
    "relation": "departments"
  }, {
    "source": "comp-3",
    "target": "depa-6",
    "relation": "departments"
  }, {
    "source": "comp-1",
    "target": "comp-6",
    "relation": "is_type"
  }, {
    "source": "comp-1",
    "target": "depa-10",
    "relation": "departments"
  }, {
    "source": "comp-1",
    "target": "depa-11",
    "relation": "departments"
  }, {
    "source": "comp-1",
    "target": "depa-4",
    "relation": "departments"
  }, {
    "source": "comp-4",
    "target": "comp-6",
    "relation": "is_type"
  }, {
    "source": "comp-4",
    "target": "depa-2",
    "relation": "departments"
  }, {
    "source": "depa-1",
    "target": "pers-6",
    "relation": "employees"
  }, {
    "source": "depa-4",
    "target": "pers-2",
    "relation": "employees"
  }]
};

export const CogneeGraph = ({data, height = 440, label, fallbackSrc, fallbackAlt}) => {
  const D3_SRC = "https://d3js.org/d3.v7.min.js";
  const COLORS = {
    Entity: "#6510F4",
    EntityType: "#A78BFA",
    DocumentChunk: "#78716C",
    TextSummary: "#2563EB",
    TextDocument: "#A8A29E",
    Company: "#6510F4",
    Department: "#A550FF",
    Person: "#0EA5E9",
    CompanyType: "#94A3B8",
    _default: "#94A3B8"
  };
  const loadD3 = () => {
    if (typeof window === "undefined") return Promise.reject(new Error("no window"));
    if (window.d3 && window.d3.forceSimulation) return Promise.resolve(window.d3);
    if (window.__cogneeD3Promise) return window.__cogneeD3Promise;
    window.__cogneeD3Promise = new Promise((resolve, reject) => {
      const script = document.createElement("script");
      script.src = D3_SRC;
      script.onload = () => window.d3 && window.d3.forceSimulation ? resolve(window.d3) : reject(new Error("d3 loaded without the force module"));
      script.onerror = () => reject(new Error("could not load d3 from " + D3_SRC));
      document.head.appendChild(script);
    });
    return window.__cogneeD3Promise;
  };
  const isDark = () => document.documentElement.classList.contains("dark");
  const colorForNode = d => d.color || COLORS[d.type] || COLORS._default;
  const labelFor = d => {
    const name = d.name || "";
    return name.length > 30 ? name.slice(0, 29) + "…" : name;
  };
  const drawGraph = (host, data, d3, height, label) => {
    const width = host.clientWidth || 680;
    const nodes = data.nodes.map(n => Object.assign({}, n));
    const links = data.links.map(l => Object.assign({}, l));
    const degree = {};
    links.forEach(l => {
      degree[l.source] = (degree[l.source] || 0) + 1;
      degree[l.target] = (degree[l.target] || 0) + 1;
    });
    host.textContent = "";
    const svg = d3.select(host).append("svg").attr("width", "100%").attr("height", height).attr("viewBox", [0, 0, width, height]).attr("role", "img").attr("aria-label", label || "Knowledge graph").style("display", "block").style("max-width", "100%").style("cursor", "grab");
    const root = svg.append("g");
    const zoom = d3.zoom().scaleExtent([0.3, 6]).on("zoom", event => root.attr("transform", event.transform));
    svg.call(zoom);
    const link = root.append("g").attr("stroke-linecap", "round").selectAll("line").data(links).join("line").attr("stroke", () => isDark() ? "#4b5563" : "#cbd5e1").attr("stroke-width", 1.2);
    const node = root.append("g").selectAll("g").data(nodes).join("g").style("cursor", "pointer");
    node.append("circle").attr("r", d => 6 + Math.min(degree[d.id] || 0, 6)).attr("fill", d => colorForNode(d)).attr("stroke", () => isDark() ? "#111827" : "#ffffff").attr("stroke-width", 1.5);
    node.append("title").text(d => d.name + " — " + d.type);
    node.append("text").text(d => labelFor(d)).attr("x", d => 9 + Math.min(degree[d.id] || 0, 6)).attr("y", 4).attr("font-size", 11).attr("font-family", "system-ui, -apple-system, sans-serif").attr("fill", () => isDark() ? "#e5e7eb" : "#374151").attr("paint-order", "stroke").attr("stroke", () => isDark() ? "#0b0b0d" : "#ffffff").attr("stroke-width", 3);
    const neighbors = {};
    links.forEach(l => {
      (neighbors[l.source] = neighbors[l.source] || ({}))[l.target] = true;
      (neighbors[l.target] = neighbors[l.target] || ({}))[l.source] = true;
    });
    node.on("mouseenter", (event, d) => {
      node.style("opacity", o => o.id === d.id || neighbors[d.id] && neighbors[d.id][o.id] ? 1 : 0.15);
      link.style("opacity", l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.08);
    }).on("mouseleave", () => {
      node.style("opacity", 1);
      link.style("opacity", 1);
    });
    const draw = () => {
      link.attr("x1", d => d.source.x).attr("y1", d => d.source.y).attr("x2", d => d.target.x).attr("y2", d => d.target.y);
      node.attr("transform", d => "translate(" + d.x + "," + d.y + ")");
    };
    const setLabelScale = counter => {
      node.selectAll("text").attr("font-size", 11 * counter).attr("stroke-width", 3 * counter).attr("y", 4 * counter).attr("x", d => (9 + Math.min(degree[d.id] || 0, 6)) * counter);
    };
    const scaleFor = box => {
      const pad = 14;
      return Math.min((width - pad * 2) / box.width, (height - pad * 2) / box.height, 1.4);
    };
    const fit = () => {
      let box = root.node().getBBox();
      if (!(box.width > 0) || !(box.height > 0)) return;
      let scale = scaleFor(box);
      for (let pass = 0; pass < 3; pass++) {
        setLabelScale(Math.min(Math.max(1 / scale, 1), 1.9));
        box = root.node().getBBox();
        scale = scaleFor(box);
      }
      const tx = (width - scale * (box.x * 2 + box.width)) / 2;
      const ty = (height - scale * (box.y * 2 + box.height)) / 2;
      svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
    };
    const simulation = d3.forceSimulation(nodes).force("link", d3.forceLink(links).id(d => d.id).distance(70).strength(0.6)).force("charge", d3.forceManyBody().strength(-320)).force("center", d3.forceCenter(width / 2, height / 2)).force("collide", d3.forceCollide(34));
    simulation.stop();
    for (let i = 0; i < 320; i++) simulation.tick();
    draw();
    fit();
    simulation.on("tick", draw);
    node.call(d3.drag().on("start", (event, d) => {
      if (!event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }).on("drag", (event, d) => {
      d.fx = event.x;
      d.fy = event.y;
    }).on("end", (event, d) => {
      if (!event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }));
    const counts = {};
    const swatch = {};
    nodes.forEach(n => {
      counts[n.type] = (counts[n.type] || 0) + 1;
      if (!swatch[n.type]) swatch[n.type] = colorForNode(n);
    });
    const legend = document.createElement("div");
    legend.style.cssText = "display:flex;flex-wrap:wrap;gap:12px;padding:8px 2px 0;font-size:12px;" + "font-family:system-ui,-apple-system,sans-serif;color:" + (isDark() ? "#9ca3af" : "#6b7280");
    Object.keys(counts).sort().forEach(type => {
      const item = document.createElement("span");
      item.style.cssText = "display:inline-flex;align-items:center;gap:5px";
      const dot = document.createElement("span");
      dot.style.cssText = "width:9px;height:9px;border-radius:50%;background:" + swatch[type];
      item.appendChild(dot);
      item.appendChild(document.createTextNode(type + " (" + counts[type] + ")"));
      legend.appendChild(item);
    });
    host.appendChild(legend);
    return () => simulation.stop();
  };
  const hostRef = useRef(null);
  const fallbackRef = useRef(null);
  useEffect(() => {
    let cancelled = false;
    let teardown = null;
    let themeObserver = null;
    let sizeObserver = null;
    const hideFallback = () => {
      if (fallbackRef.current) fallbackRef.current.style.display = "none";
    };
    const whenVisible = host => host.offsetParent !== null ? Promise.resolve() : new Promise(resolve => {
      const done = () => {
        if (sizeObserver) sizeObserver.disconnect();
        resolve();
      };
      sizeObserver = new ResizeObserver(() => {
        if (host.offsetParent !== null) done();
      });
      sizeObserver.observe(host);
      setTimeout(done, 10000);
    });
    loadD3().then(d3 => hostRef.current ? whenVisible(hostRef.current).then(() => d3) : d3).then(d3 => {
      if (cancelled || !hostRef.current) return;
      teardown = drawGraph(hostRef.current, data, d3, height, label);
      hideFallback();
      let wasDark = isDark();
      themeObserver = new MutationObserver(() => {
        if (isDark() === wasDark || cancelled || !hostRef.current) return;
        wasDark = isDark();
        if (teardown) teardown();
        teardown = drawGraph(hostRef.current, data, d3, height, label);
      });
      themeObserver.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["class"]
      });
    }).catch(err => {
      console.warn("[CogneeGraph] falling back to the static image:", err.message);
    });
    return () => {
      cancelled = true;
      if (teardown) teardown();
      if (themeObserver) themeObserver.disconnect();
      if (sizeObserver) sizeObserver.disconnect();
    };
  }, [data, height, label]);
  return <div style={{
    position: "relative",
    minHeight: height
  }} data-cognee-graph="">
      <div ref={hostRef} style={{
    minHeight: height
  }} />
      {fallbackSrc ? <img ref={fallbackRef} src={fallbackSrc} alt={fallbackAlt || label || "Knowledge graph"} style={{
    position: "absolute",
    top: 0,
    left: 0,
    width: "100%",
    height: height,
    objectFit: "contain"
  }} /> : null}
    </div>;
};

You have your org data as plain exports — one JSON file listing companies and their departments, another listing people and where they work — and you want it in memory as a connected graph of companies, departments, and employees, without an LLM re-deriving structure you already know.

## What You'll Build

Two JSON files — `companies.json` and `people.json` — are loaded and mapped onto typed `DataPoint` classes, then written straight into the graph and vector stores by a two-task custom pipeline: no chunking, no extraction, no cognify pass. What comes out is an org chart you can open in a browser: `Company` nodes linked to their `Department` nodes, each department linked to the `Person` nodes that work in it, with every name embedded for semantic search. Node IDs are derived from the data itself, so the same department name reaching the pipeline twice lands on one node rather than two.

The demo ships as two variants of the same scenario, so this page walks through both: a high-level script that lets `identity_fields` deduplicate for it, and a low-level script that assembles and deduplicates the graph by hand.

The complete runnable scripts are in
[`examples/demos/custom_pipelines/organizational_hierarchy`](https://github.com/topoteretes/cognee/tree/main/examples/demos/custom_pipelines/organizational_hierarchy) —
this page walks through their key moments rather than reproducing them.

## Features in Play

* [DataPoints](/core-concepts/building-blocks/datapoints) — `Person`, `Department`, `Company`, and `CompanyType` are the node types; `index_fields` decides what gets embedded and `identity_fields` derives the deterministic ID that collapses repeats
* [Custom Data Models](/guides/custom-data-models) — `add_data_points` persists the typed objects and their nesting as nodes and edges, with no LLM in the path
* [Custom Tasks and Pipelines](/guides/custom-tasks-pipelines) — two `Task` objects run by `run_tasks` against a dataset: one maps JSON to DataPoints, one stores them
* [Graph Visualization](/guides/graph-visualization) — both scripts close by rendering the org chart to an HTML file you can open
* [Search](/core-concepts/main-operations/legacy-operations/search) — the low-level variant finishes with a `GRAPH_COMPLETION` query, proving the hand-built nodes answer questions like any other memory

## How It Looks

This is the graph the high-level script writes, loaded from a real run of it. Drag a node to pull it out of the tangle, scroll to zoom, and hover one to fade out everything it does not touch — hovering a department is the quickest way to see exactly who works in it.

<Frame caption="The org chart from a real run: one CompanyType node at the centre, five companies around it, each linked to the departments it declares, and each department to the people in it.">
  <CogneeGraph data={orgChartGraph} height={440} label="Org chart knowledge graph with 27 nodes and 26 edges" fallbackSrc="/images/examples/org-chart-graph.png" fallbackAlt="Force-directed graph with a central CompanyType node joined to five Company nodes, each Company joined to its Department nodes, and each Department joined to the Person nodes working in it." />
</Frame>

Every node and edge here comes from the run's `visualize_graph` export, so the shape is exactly what lands in your graph store: `Research & Development` sits on GreenFuture with no one attached to it, because the company declares the department but no one in `people.json` works there.

## What to Expect

The lines below come from one real run of the high-level script, trimmed. Nothing in this demo calls an LLM, so the node and edge counts are identical on every run — only the UUIDs, timestamps, and paths change. A first run also prints a long block of relational migrations as cognee builds its tables; that block is setup, not part of the demo.

**The pipeline starts with both JSON files already in hand.** `build_lightweight_data_object` has wrapped the two files into a single `LightweightData` DataPoint, so the payload printed at the start of the run is the whole input — five companies and ten people — before any task has touched it.

```text theme={null}
status='PipelineRunStarted' pipeline_run_id=UUID('40da9a6f-3948-43be-a90f-e551570c40dd') dataset_id=UUID('94add405-52f0-5e30-871b-b2e8eeda31c7') dataset_name='test_dataset' payload=[LightweightData(id=UUID('c22f2fa6-5eee-5a20-a8d5-f3a1d10dbbbd'), ...
```

**The two tasks finish in about three seconds.** `ingest_files` maps the payload and `add_data_points` writes the result. There is no extraction step between them, which is why the whole pipeline costs less than a single LLM call would.

```text theme={null}
2026-09-11T15:24:05.784975 [info     ] Function task started: `ingest_files` [run_tasks_base]
2026-09-11T15:24:05.792215 [info     ] Coroutine task started: `add_data_points` [run_tasks_base]
...
2026-09-11T15:24:08.755657 [info     ] Coroutine task completed: `add_data_points` [run_tasks_base]
2026-09-11T15:24:08.762174 [info     ] Function task completed: `ingest_files` [run_tasks_base]
2026-09-11T15:24:08.768537 [info     ] Pipeline run completed: `07f15429-bcc9-56e7-8058-6ff9d3fd6372` [run_tasks_with_telemetry()]
```

**The graph that lands is 27 nodes and 26 edges, and the arithmetic is what tells you the mapping was right.** Five `Company` nodes, eleven `Department` nodes, ten `Person` nodes, and the one `CompanyType` all five companies point at make 27. Eleven company-to-department edges, ten department-to-employee edges, and five `is_type` edges make 26. Had a department name landed as two nodes, both numbers would be higher.

```text theme={null}
2026-09-11T15:24:08.791366 [info     ] Retrieved 27 nodes and 26 edges in 0.00 seconds [cognee.shared.logging_utils]
2026-09-11T15:24:08.797151 [info     ] Neighborhood retrieval (2-hop): 27 nodes and 26 edges in 0.01s [cognee.shared.logging_utils]
```

**One warning is expected and does not mean the run failed.** `add_data_points` did embed every `index_fields` value — the `Person_name`, `Department_name`, and `Company_name` collections are written and searchable. The warning comes from the renderer's semantic-map layer, which only recognizes cognee's built-in node types and so has nothing to plot for custom DataPoints. The graph view itself is complete.

```text theme={null}
2026-09-11T15:24:08.798832 [info     ] fetch_node_embeddings: resolved 0/27 node embeddings across 0 collection(s) [embedding_join]
2026-09-11T15:24:08.798890 [warning  ] fetch_node_embeddings: no embeddings resolved — the semantic map will be empty. Missing collections: none. Unmapped node types: ['Department', 'Company', 'Person', 'CompanyType']. [embedding_join]
```

## Before You Start

* Complete [Quickstart](/getting-started/quickstart) to understand basic operations
* Ensure you have [LLM Providers](/setup-configuration/llm-providers) configured — `add_data_points` embeds every `index_fields` value, so an embedding provider is required even though neither script extracts anything with an LLM; the low-level variant's closing query also needs a completion model
* Run both scripts from a checkout of the cognee repo: they read `companies.json` and `people.json` from the `data/` folder next to them, and write their HTML renders into a sibling `.artifacts/` folder
* The high-level script starts with `prune.prune_data()` and `prune.prune_system(metadata=True)`, which wipes the configured instance — point it at a scratch instance rather than memory you want to keep. The low-level script pins its system root to a `.cognee_system` folder beside itself before pruning, so it stays self-contained

## How It Works

### Stage 1: Model the Org Chart as Deduplicating DataPoints

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_example.py`

```python theme={null}
class Person(DataPoint):
    name: str
    # "index_fields": fields to embed for vector search
    # "identity_fields": fields used to generate deterministic IDs (deduplication)
    metadata: dict = {"index_fields": ["name"], "identity_fields": ["name"]}


class Department(DataPoint):
    name: str
    employees: list[Person]
    metadata: dict = {"index_fields": ["name"], "identity_fields": ["name"]}
```

These classes are the entire schema of the resulting graph — `Company` and `CompanyType` follow the same pattern below them. Two things happen per class: `index_fields` names the field that gets embedded, so the graph is searchable by name; `identity_fields` makes the node's UUID a deterministic function of that name, so building `Person(name="John Doe")` twice lands on one node instead of two. The `employees` field is what makes this a graph rather than two tables — a list of `Person` objects on a `Department` becomes edges from that department to those people.

### Stage 2: Map the JSON Rows onto Nested DataPoints

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_example.py`

```python theme={null}
def ingest_files(data: list[Any]) -> list[Company]:
    # With identity_fields, DataPoints with the same name automatically get the same UUID.
    # No manual dict-based deduplication needed — just create instances freely.
    all_companies: list[Company] = []

    # Single CompanyType node shared across all data items (deterministic ID via identity_fields)
    company_type = CompanyType()

    for data_item in data:
        people = data_item.people
        companies = data_item.companies

        # Build departments with their employees
        dept_employees: dict[str, list[Person]] = {}
        for person in people:
            dept_name = person["department"]
            if dept_name not in dept_employees:
                dept_employees[dept_name] = []
            dept_employees[dept_name].append(Person(name=person["name"]))

        departments = {
            name: Department(name=name, employees=employees)
            for name, employees in dept_employees.items()
        }

        for company in companies:
            company_departments = [
                departments.get(dept_name, Department(name=dept_name, employees=[]))
                for dept_name in company["departments"]
            ]
            all_companies.append(
                Company(name=company["name"], departments=company_departments, is_type=company_type)
            )

    return all_companies
```

This is the first pipeline task, and it is ordinary Python: group people by their `department` field, build one `Department` per group, then hang the departments each company declares off a `Company`. Nothing here checks whether a node already exists. A company that declares a department nobody works in still gets a `Department` node — created on the spot with no employees — and if another company declares the same one, `identity_fields` gives both objects the same UUID, so the two collapse into a single shared node when they are stored. The function returns only the companies; the departments and people reach the graph because they hang off them.

### Stage 3: Run the Two-Task Pipeline

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_example.py`

```python theme={null}
    # Run tasks expects a list of data even if it is just one document
    data = [{"companies": companies, "people": people}]

    pipeline = run_tasks(
        [Task(ingest_files), Task(add_data_points)],
        dataset_id=datasets[0].id,
        data=build_lightweight_data_object(data),
        incremental_loading=False,
    )
```

`main()` prunes, calls `setup()` to create the relational tables, resolves the default user, and creates a `test_dataset` with `load_or_create_datasets` before this point — the dataset is what the pipeline run is tracked against. `run_tasks` then chains the two tasks: `ingest_files` returns `Company` objects and `add_data_points` writes them, their nested departments and people, and the edges between them into the graph and vector stores. `build_lightweight_data_object` wraps the raw dict in a `LightweightData` DataPoint with a `uuid5` ID, because the pipeline expects DataPoints rather than plain dicts. The run ends with `visualize_graph`, which writes the org chart to `.artifacts/organizational_hierarchy_pipeline_example.html`.

### Stage 4: Model the Same Graph Without identity\_fields

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_low_level_example.py`

```python theme={null}
class Person(DataPoint):
    """Represent a person."""

    name: str
    metadata: dict = {"index_fields": ["name"]}


class Department(DataPoint):
    """Represent a department."""

    name: str
    employees: list[Person]
    metadata: dict = {"index_fields": ["name"]}
```

The low-level variant declares the same four node types, with one difference that shapes the rest of the script: no `identity_fields`. Names are still embedded, but each constructed instance gets its own random UUID, so two `Person(name="John Doe")` objects are two nodes. Everything the next stage does by hand exists to prevent that.

### Stage 5: Deduplicate People and Departments by Hand

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_low_level_example.py`

```python theme={null}
def build_people_nodes(people: Iterable[Mapping[str, Any]]) -> dict:
    """Build person nodes keyed by name."""
    nodes = {p["name"]: Person(name=p["name"]) for p in people if p.get("name")}
    return nodes


def group_people_by_department(people: Iterable[Mapping[str, Any]]) -> dict:
    """Group person names by department."""
    groups = defaultdict(list)
    for person in people:
        name = person.get("name")
        if not name:
            continue
        dept = person.get("department", "Unknown")
        groups[dept].append(name)
    return groups
```

Every node here is created once and then referenced by name from a dict — that keying, not the model, is what makes the graph deduplicate. `build_companies` chains the helpers in order: collect people and companies from the payloads, build the person and department node dicts, then `attach_departments_to_companies` and `attach_employees_to_departments` wire the objects together by looking each name up. `remove_duplicates_preserve_order` is needed because the same person can be listed twice for a department, and `collect_declared_departments` unions the departments people work in with the ones companies declare, so a department with no employees still gets a node.

### Stage 6: Store the Graph, Index Its Edges, and Query It

Source: `examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_low_level_example.py`

```python theme={null}
    # Build and run pipeline
    tasks = [Task(ingest_payloads), Task(add_data_points)]
    pipeline = run_tasks(tasks, dataset_id, None, user, "demo_pipeline")
    async for status in pipeline:
        logger.info("Pipeline status: %s", status)

    # Post-process: index graph edges and visualize
    await index_graph_edges()
    await visualize_graph(str(GRAPH_HTML))

    # Run query against graph
    completion = await search(
        query_text="Who works for GreenFuture Solutions?",
        query_type=SearchType.GRAPH_COMPLETION,
    )
```

The pipeline is the same two-task shape, run against a `demo_dataset` with `None` as the data argument — `ingest_payloads` falls back to loading the bundled JSON files itself. After the nodes land, `index_graph_edges` embeds the relationship names so edges are retrievable and not just traversable, and `visualize_graph` writes the HTML render. The closing `GRAPH_COMPLETION` search is the point of the whole exercise: nothing about the query knows the graph was assembled by hand, so asking who works for GreenFuture Solutions walks company → department → employee edges and answers from them.

## Run It

```bash theme={null}
uv run python examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_example.py
uv run python examples/demos/custom_pipelines/organizational_hierarchy/organizational_hierarchy_pipeline_low_level_example.py
```

The high-level run is the one walked through above; it closes by logging the path of the render it wrote to `.artifacts/organizational_hierarchy_pipeline_example.html`, next to the script — open that file to see companies linked to departments linked to people. The low-level run logs its pipeline statuses through `logging` as `Pipeline status: ...` lines, writes `.artifacts/graph_visualization.html`, and ends by logging `Graph completion result: ...` with the LLM's answer to "Who works for GreenFuture Solutions?" — the proof that the hand-built graph is queryable.

## Choosing Between the Two Variants

Start from the high-level script. Declaring `identity_fields` moves deduplication into the node's identity, so the mapping code is free to construct objects wherever it is convenient and the store collapses the repeats — the same property that makes re-running the import idempotent rather than doubling the graph. The low-level script is worth reading when you need to see what that buys you: it is the same org chart, with keyed dicts, ordered-unique helpers, and attach passes standing in for the deterministic IDs. Reach for its shape when identity is not a field you have — when nodes are distinguished by something you compute rather than something in the data — and for `index_graph_edges`, which either variant can call once the nodes are stored.

<Columns cols={2}>
  <Card title="DataPoints" icon="circle" href="/core-concepts/building-blocks/datapoints">
    How `index_fields`, `identity_fields`, and nested models shape the graph a DataPoint becomes.
  </Card>

  <Card title="Custom Data Models" icon="circle-stop" href="/guides/custom-data-models">
    More on modeling your own node types and storing them with `add_data_points`.
  </Card>

  <Card title="Custom Tasks and Pipelines" icon="workflow" href="/guides/custom-tasks-pipelines">
    Writing your own tasks and running them as a pipeline, step by step.
  </Card>

  <Card title="Reading the Visualization" icon="eye" href="/guides/reading-the-visualization">
    What each tab of the rendered org chart shows, and which one to reach for.
  </Card>
</Columns>
