Tutorial Examples Connectors Knowledge Graph LLM Incremental Processing ~6 min read

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.


Updated Jul 8, 2026

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.

Neo4j Property Graph

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: 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:

Property graph model: a Person node ATTENDED a Meeting as organizer, is ASSIGNED_TO a Task, and the Meeting DECIDED that Task. Circles are nodes; arrows are relationships.

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 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, wrapped in a coco.App:

python
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
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
@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.

Dataclass record schemas map to Neo4j: the Meeting, Person, and Task node dataclasses plus the AttendedRel edge payload become Person, Meeting, and Task nodes joined by ATTENDED, DECIDED, and ASSIGNED_TO relationships.

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
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
_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
@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.

Three phases: Phase 1 extracts each file with an LLM and declares Meeting and Task nodes plus DECIDED edges; Phase 2 resolves person names across the corpus so Sarah and Sarah Chen collapse to one; Phase 3 declares canonical Person nodes and the ATTENDED and ASSIGNED_TO edges, writing the graph to Neo4j.

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
@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
@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
@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
@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 running locally. Default connection bolt://localhost:7687, browser at http://localhost
    , username neo4j, password cocoindex.
  • An OpenAI API key (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.

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

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

Neo4j Property Graph

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.

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

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

How do I build a knowledge graph from meeting notes?

Ingest your meeting notes (here Markdown files in Google Drive), split each document into individual meetings, use an LLM to extract structured data (decisions, tasks, owners, participants), collect nodes and relationships, and export them to a graph database like Neo4j. The pipeline turns unstructured notes into a queryable graph so you can ask questions like "who attended budget-planning meetings?". See Architecture overview.

How do I extract structured data from meeting notes with an LLM?

Define Python dataclasses for the schema you want (Person, Task, and a top-level Meeting with organizer, participants, and tasks), then pass output_type=Meeting to cocoindex.functions.ExtractByLlm. The LLM uses the dataclass schema as its extraction template and returns structured data matching the Python types, which is far more reliable than free-form output. See Define meeting schema.

How does CocoIndex avoid reprocessing unchanged documents?

CocoIndex does incremental processing with automatic change tracking: it lists files with their last-modified time, identifies only files added or modified since the last successful run, skips unchanged files entirely, and passes only changed documents downstream. The post notes that in an enterprise with 1% daily churn, only 1% of documents trigger downstream work, so unchanged files never hit your LLM API. See Add source and collector.

How do I split a document containing multiple meetings into separate meetings?

Use cocoindex.functions.SplitBySeparators with a regex that matches Markdown headers preceded by blank lines (r"\n\n##?\ ") and keep_separator="RIGHT", which keeps each header with the segment that follows it so each section becomes a separate meeting with its context preserved. See Extract meetings.

How does CocoIndex deduplicate nodes when exporting to Neo4j?

Each export declares primary_key_fields, and CocoIndex matches nodes by those keys. Meetings are keyed by note_file and time; Person nodes are declared with name as the key and Task nodes with description, using Neo4jDeclaration for nodes that only appear inside relationships. If a node with the same primary key already exists, CocoIndex reuses it instead of creating a duplicate. See Map to graph database.

How much does incremental processing reduce LLM costs for a knowledge graph pipeline?

The post reports that changing only a few meeting-note files triggers re-processing of just those files, giving a 99%+ reduction in LLM API costs for a typical 1% daily churn, along with lower compute consumption, less database I/O, and shorter pipeline execution time. See Incremental processing with change detection.

What else can this meeting-notes knowledge graph pattern be used for?

The post lists several enterprise applications of the same source → detect changes → split → extract → collect → export pattern: research paper analysis, customer support ticket analysis, email thread summarization, compliance documentation tracking, and competitive intelligence. Each benefits from incremental processing because the underlying documents are continually edited. See Real-world enterprise applications.

Can the meeting notes knowledge graph update in real time?

Yes. By changing the execution mode from batch to live, the pipeline continuously monitors Google Drive and updates the knowledge graph in near real time, reflecting edits and deletions within the next polling interval. The export step is also incremental, mutating the graph only for nodes and relationships that actually changed. See Key CocoIndex features demonstrated.