---
title: "A knowledge graph from meeting notes that auto-updates"
description: "Build a self-updating Neo4j knowledge graph from meeting notes with CocoIndex v1: LLM-extracted decisions, tasks, and owners, with embedding-based dedupe."
last_updated: 2025-12-08
doc_version: "2025-12-08"
canonical: https://cocoindex.io/blogs/meeting-notes-graph/
---
# A knowledge graph from meeting notes that auto-updates

> Build a self-updating Neo4j knowledge graph from meeting notes with CocoIndex v1: LLM-extracted decisions, tasks, and owners, with embedding-based dedupe.

Published: 2025-12-08 · Canonical: https://cocoindex.io/blogs/meeting-notes-graph/

Meeting notes capture decisions, action items, who was in the room, and how people and tasks connect. Most teams keep them as static documents, reachable only through text search. A knowledge graph lets you query them like a database instead: *"Who attended the budget planning meetings?"* or *"What tasks did Sarah get assigned across every meeting?"*

To answer questions like these you extract structured information from unstructured notes and store it as a graph, which makes relationship queries direct instead of impossible.

This post walks through the **Meeting Notes Graph** example: a CocoIndex v1 pipeline that reads Markdown meeting notes from Google Drive, extracts structure with an LLM, deduplicates person names with embedding-based entity resolution, and builds a queryable graph in Neo4j.

The project is open-sourced in the [CocoIndex v1 repo](https://github.com/cocoindex-io/cocoindex/tree/main/examples/meeting_notes_graph_neo4j).

## Why a graph, and why incremental

Meeting notes are living documents. Attendee names get corrected, tasks get reassigned, and decisions get revised. A batch pipeline that rebuilds the whole graph on every edit is wasteful once you have more than a handful of files, and it makes near-real-time freshness expensive.

CocoIndex processes [incrementally](https://cocoindex.io/blogs/incremental-processing): it detects which files changed and reprocesses only those. Unchanged notes never hit the LLM, and the graph write is a no-op for anything that did not change. The heavy LLM extraction step is memoized, so as long as a section's text and the model are unchanged, the cached result is reused.

## The graph model

We build a property graph with three node types and three edge types:

**Nodes**

- **`Meeting`**: one per meeting section, with its date and notes
- **`Person`**: a canonical individual (organizer, participant, or task assignee)
- **`Task`**: an action item decided in a meeting

**Edges**

- **`ATTENDED`**: `Person → Meeting`, carrying an `is_organizer` flag
- **`DECIDED`**: `Meeting → Task`
- **`ASSIGNED_TO`**: `Person → Task`

To learn more about property graphs, see CocoIndex's [Property Graph Targets](https://cocoindex.io/docs/programming_guide/target_state/) documentation.

## Define the pipeline

CocoIndex v1 is declarative and Python-native: you write plain async Python and the Rust engine handles incremental processing. The whole example lives in one [`main.py`](https://github.com/cocoindex-io/cocoindex/blob/main/examples/meeting_notes_graph_neo4j/main.py), wrapped in a `coco.App`:

```python title=main.py
import cocoindex as coco
from cocoindex.connectors import google_drive, neo4j
from cocoindex.ops.entity_resolution import ResolvedEntities, resolve_entities
from cocoindex.ops.entity_resolution.llm_resolver import LlmPairResolver
from cocoindex.ops.sentence_transformers import SentenceTransformerEmbedder
from cocoindex.resources.id import IdGenerator

app = coco.App(coco.AppConfig(name="MeetingNotesGraphNeo4j"), app_main)
```

### Shared resources

The Neo4j connection, the two LLM models, and the embedder are provided once in the app lifespan and retrieved anywhere with `coco.use_context`. The `neo4j.ConnectionFactory` reads standard connection settings from the environment, defaulting to a local Neo4j:

```python title=main.py
KG_DB = coco.ContextKey[neo4j.ConnectionFactory]("kg_db")
LLM_MODEL = coco.ContextKey[str]("llm_model", detect_change=True)
RESOLUTION_LLM_MODEL = coco.ContextKey[str]("resolution_llm_model", detect_change=True)
EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder]("embedder", detect_change=True)

@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -> AsyncIterator[None]:
    builder.provide(
        KG_DB,
        neo4j.ConnectionFactory(
            uri=os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
            auth=(
                os.environ.get("NEO4J_USER", "neo4j"),
                os.environ.get("NEO4J_PASSWORD", "cocoindex"),
            ),
            database=os.environ.get("NEO4J_DATABASE", "neo4j"),
        ),
    )
    builder.provide(LLM_MODEL, os.environ.get("LLM_MODEL", "openai/gpt-5-mini"))
    builder.provide(
        RESOLUTION_LLM_MODEL,
        os.environ.get("RESOLUTION_LLM_MODEL", "openai/gpt-5-mini"),
    )
    builder.provide(
        EMBEDDER,
        SentenceTransformerEmbedder("Snowflake/snowflake-arctic-embed-xs"),
    )
    yield
```

`detect_change=True` means swapping a model or the embedder re-runs only the affected steps.

### Node and edge schemas

Node rows are plain dataclasses. The primary key of each becomes the node's identity in Neo4j, so re-runs reconcile existing nodes in place instead of duplicating them:

```python title=main.py
@dataclass
class Meeting:
    id: int
    note_file: str
    time: datetime.date
    note: str

@dataclass
class Person:
    name: str  # canonical

@dataclass
class Task:
    description: str

@dataclass
class AttendedRel:
    is_organizer: bool
```

`AttendedRel` is the edge payload for `ATTENDED`. `DECIDED` and `ASSIGNED_TO` carry no payload, so they are declared without a record. Relation primary keys are auto-derived by the Neo4j connector from `(from_id, to_id)`, giving exactly one edge per endpoint pair.

Each dataclass maps directly to the graph: a record becomes a node keyed by its primary key, and a declared relation becomes an edge between two endpoint keys.

### The extraction schema

The LLM extraction target is a set of Pydantic models. `instructor` uses these as the response schema, so the model returns structured data that matches the Python types instead of free-form text:

```python title=main.py
class ExtractedPerson(pydantic.BaseModel):
    name: str = pydantic.Field(
        description="Full name of the person, as written in the note."
    )

class ExtractedTask(pydantic.BaseModel):
    description: str = pydantic.Field(
        description="Concise, standalone description of the task or action item."
    )
    assigned_to: list[ExtractedPerson] = pydantic.Field(default_factory=list)

class ExtractedMeeting(pydantic.BaseModel):
    time: datetime.date
    note: str
    organizer: ExtractedPerson
    participants: list[ExtractedPerson] = pydantic.Field(default_factory=list)
    tasks: list[ExtractedTask] = pydantic.Field(default_factory=list)
```

### Split, then extract

A single note file often holds several meetings. `_split_meetings` breaks the text at Markdown headings (`#` or `##`) so each section becomes one meeting:

```python title=main.py
_HEADING_RE = re.compile(r"\n\n##?\s+")

def _split_meetings(text: str) -> list[str]:
    parts = _HEADING_RE.split("\n\n" + text)
    return [p.strip() for p in parts if p.strip()]
```

Each section goes to `extract_meeting`, which calls the model through LiteLLM and `instructor`. The model comes from the `LLM_MODEL` context (default `openai/gpt-5-mini`), and `memo=True` caches the result so an unchanged section is never re-extracted:

```python title=main.py
@coco.fn(memo=True)
async def extract_meeting(section_text: str) -> ExtractedMeeting:
    client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.JSON)
    result = await client.chat.completions.create(
        model=coco.use_context(LLM_MODEL),
        response_model=ExtractedMeeting,
        messages=[
            {"role": "system", "content": EXTRACT_PROMPT},
            {"role": "user", "content": section_text},
        ],
    )
    return ExtractedMeeting.model_validate(result.model_dump())
```

## Three phases

The pipeline runs in three phases. Phase 1 extracts each file and declares the nodes and edges it can build on its own. Phase 2 resolves person names across the whole corpus. Phase 3 declares the canonical `Person` nodes and the edges that touch people.

### Phase 1: per-file extraction

`process_file` reads a Drive file, splits it, and for each meeting declares a `Meeting` node and its `Task` nodes plus `DECIDED` edges. Meeting IDs come from an `IdGenerator` keyed on the meeting date. Person names are not resolved yet, so the raw names are collected into a `MeetingExtraction` and returned for later phases:

```python title=main.py
@coco.fn(memo=True)
async def process_file(
    file: google_drive.DriveFile,
    meeting_table: neo4j.TableTarget[Meeting],
    task_table: neo4j.TableTarget[Task],
    decided_rel: neo4j.RelationTarget[Any],
) -> list[MeetingExtraction]:
    text = await file.read_text()
    note_file = file.file_path.path.as_posix()
    id_generator = IdGenerator()
    extractions = []
    for section in _split_meetings(text):
        extracted = await extract_meeting(section)
        meeting_id = await id_generator.next_id(extracted.time)

        meeting_table.declare_record(
            row=Meeting(
                id=meeting_id,
                note_file=note_file,
                time=extracted.time,
                note=extracted.note,
            )
        )

        for task in extracted.tasks:
            task_table.declare_record(row=Task(description=task.description))
            decided_rel.declare_relation(from_id=meeting_id, to_id=task.description)

        extractions.append(
            MeetingExtraction(
                meeting_id=meeting_id,
                organizer=extracted.organizer.name,
                participants=[p.name for p in extracted.participants],
                task_assignees=[
                    (t.description, [a.name for a in t.assigned_to])
                    for t in extracted.tasks
                ],
            )
        )
    return extractions
```

### Phase 2: person entity resolution

The same person shows up under different names across files: "Sarah" in one note, "Sarah Chen" in another. If you create a `Person` node per raw string, the graph fragments and relationship queries miss edges.

`_resolve_persons` deduplicates raw names with embedding-based entity resolution. Candidate matches are found by embedding similarity, then confirmed pairwise by an LLM. The result maps every raw name to a canonical one:

```python title=main.py
@coco.fn(memo=True)
async def _resolve_persons(raw_persons: set[str]) -> ResolvedEntities:
    return await resolve_entities(
        entities=raw_persons,
        embedder=coco.use_context(EMBEDDER),
        resolve_pair=LlmPairResolver(model=coco.use_context(RESOLUTION_LLM_MODEL)),
    )
```

`ResolvedEntities` exposes `.canonicals()` for the deduplicated set and `.canonical_of(raw_name)` to look up which canonical a raw name folds into. So "Sarah" and "Sarah Chen" collapse to a single `Person` node.

### Phase 3: canonical people and their edges

With names resolved, `create_person_relations` declares one `Person` node per canonical name, then the person-touching edges. `ATTENDED` aggregates organizer and participants so a person listed as both gets a single edge with `is_organizer=True`. `ASSIGNED_TO` dedupes per canonical person and task:

```python title=main.py
@coco.fn
async def create_person_relations(
    meetings: list[MeetingExtraction],
    persons: ResolvedEntities,
    person_table: neo4j.TableTarget[Person],
    attended_rel: neo4j.RelationTarget[Any],
    assigned_rel: neo4j.RelationTarget[Any],
) -> None:
    for canonical_name in persons.canonicals():
        person_table.declare_record(row=Person(name=canonical_name))

    for m in meetings:
        attendees: dict[str, bool] = {persons.canonical_of(m.organizer): True}
        for p in m.participants:
            attendees.setdefault(persons.canonical_of(p), False)

        for canonical, is_organizer in attendees.items():
            attended_rel.declare_relation(
                from_id=canonical,
                to_id=m.meeting_id,
                record=AttendedRel(is_organizer=is_organizer),
            )

        for task_desc, assignees in m.task_assignees:
            seen: set[str] = set()
            for raw in assignees:
                canonical = persons.canonical_of(raw)
                if canonical in seen:
                    continue
                seen.add(canonical)
                assigned_rel.declare_relation(from_id=canonical, to_id=task_desc)
```

### Wiring it together

`app_main` mounts the three node tables and three relation targets, then runs the phases. Node tables take a `TableSchema` and a primary key; relations mount without a schema so the connector derives their PK from the endpoints. The Google Drive source is iterated with `async for`, and each file is processed under its own component path so change detection is per-file:

```python title=main.py
@coco.fn
async def app_main() -> None:
    meeting_table = await neo4j.mount_table_target(
        KG_DB, "Meeting",
        await neo4j.TableSchema.from_class(Meeting, primary_key="id"),
        primary_key="id",
    )
    person_table = await neo4j.mount_table_target(
        KG_DB, "Person",
        await neo4j.TableSchema.from_class(Person, primary_key="name"),
        primary_key="name",
    )
    task_table = await neo4j.mount_table_target(
        KG_DB, "Task",
        await neo4j.TableSchema.from_class(Task, primary_key="description"),
        primary_key="description",
    )

    attended_rel = await neo4j.mount_relation_target(
        KG_DB, "ATTENDED", person_table, meeting_table
    )
    decided_rel = await neo4j.mount_relation_target(
        KG_DB, "DECIDED", meeting_table, task_table
    )
    assigned_rel = await neo4j.mount_relation_target(
        KG_DB, "ASSIGNED_TO", person_table, task_table
    )

    # Phase 1: per-file extraction
    source = google_drive.GoogleDriveSource(
        service_account_credential_path=os.environ["GOOGLE_SERVICE_ACCOUNT_CREDENTIAL"],
        root_folder_ids=[
            f.strip()
            for f in os.environ["GOOGLE_DRIVE_ROOT_FOLDER_IDS"].split(",")
            if f.strip()
        ],
    )
    file_coros = []
    async for path_key, file in source.items():
        file_coros.append(
            coco.use_mount(
                coco.component_subpath("file", path_key),
                process_file, file, meeting_table, task_table, decided_rel,
            )
        )
    per_file = list(await asyncio.gather(*file_coros))
    all_meetings = [m for ms in per_file for m in ms]

    # Phase 2: person entity resolution
    raw_persons: set[str] = set()
    for m in all_meetings:
        raw_persons.add(m.organizer)
        raw_persons.update(m.participants)
        for _task_desc, assignees in m.task_assignees:
            raw_persons.update(assignees)
    persons = await coco.use_mount(
        coco.component_subpath("resolve_persons"), _resolve_persons, raw_persons
    )

    # Phase 3: declare Person nodes + person-touching relations
    await coco.mount(
        coco.component_subpath("person_relations"),
        create_person_relations,
        all_meetings, persons, person_table, attended_rel, assigned_rel,
    )
```

When a component finishes, CocoIndex compares its declared nodes and edges against the previous run and applies only the differences: new items are inserted, changed ones updated, and items whose source file disappeared are removed. Nothing that stayed the same is rewritten.

## Run

### Prerequisites

- [Neo4j](https://cocoindex.io/docs/connectors/neo4j/) running locally. Default connection `bolt://localhost:7687`, browser at [http://localhost:7474](http://localhost:7474/), username `neo4j`, password `cocoindex`.
- An [OpenAI API key](https://cocoindex.io/docs/ops/litellm/) (`OPENAI_API_KEY`). The default models are `openai/gpt-5-mini` for both extraction and resolution.
- Google Drive service-account credentials. Create a Google Cloud service account, download its JSON key, share the source folders with the service-account email, and collect the folder IDs. See [Setup for Google Drive](https://cocoindex.io/docs/connectors/google_drive/).

Set the environment variables:

```sh
export OPENAI_API_KEY=sk-...
export GOOGLE_SERVICE_ACCOUNT_CREDENTIAL=/absolute/path/to/service_account.json
export GOOGLE_DRIVE_ROOT_FOLDER_IDS=folderId1,folderId2
```

`GOOGLE_DRIVE_ROOT_FOLDER_IDS` accepts a comma-separated list.

### Build the graph

```sh
pip install -e .
cocoindex update main
```

Use `cocoindex update -L main` for live mode, which keeps watching Drive and updates the graph as notes change.

### Browse the graph

Open Neo4j Browser at [http://localhost:7474](http://localhost:7474/) and run Cypher:

```cypher
// All relationships
MATCH p=()-->() RETURN p

// Who attended which meetings (including organizer)
MATCH (p:Person)-[:ATTENDED]->(m:Meeting)
RETURN p, m

// Tasks decided in meetings
MATCH (m:Meeting)-[:DECIDED]->(t:Task)
RETURN m, t

// Task assignments
MATCH (p:Person)-[:ASSIGNED_TO]->(t:Task)
RETURN p, t
```

## Where else this applies

The same pattern (ingest → split → extract → resolve entities → build graph) fits any text-heavy domain where the entities and their relationships matter more than the raw text:

- **Research papers**: build a graph of concepts, authors, and citations across a repository, and keep it current as papers are added or revised.
- **Support tickets**: link issues, solutions, customers, and recurring patterns across a large, frequently edited ticket base.
- **Compliance documents**: extract requirements from policies and trace how a policy change cascades through related obligations.
- **Competitive intelligence**: connect companies, products, and market moves extracted from public filings and news.

## Summary

This example turns unstructured meeting notes into a queryable Neo4j graph: an LLM extracts structure, embedding-based entity resolution keeps one node per real person, and CocoIndex's incremental processing keeps the graph in sync as notes change without reprocessing the whole corpus. The same source → split → extract → resolve → graph template carries over to any domain where relationships are the thing you want to query.

## Support CocoIndex ❤️

If this example was helpful, the easiest way to support CocoIndex is to [give the project a ⭐ on GitHub](https://github.com/cocoindex-io/cocoindex).

Your stars help us grow the community, stay motivated, and keep shipping better tools for real-time data ingestion and transformation.

## Sitemap

- [Blog index](https://cocoindex.io/blogs/)
- [Site index (llms.txt)](https://cocoindex.io/llms.txt)
- [Full blog corpus](https://cocoindex.io/llms-full.txt)
- [Markdown sitemap](https://cocoindex.io/sitemap.md)
- [XML sitemap](https://cocoindex.io/sitemap.xml)
- [RSS feed](https://cocoindex.io/blogs/rss.xml)
