AI Engineer World's Fair 2025
Intro to GraphRAG
Read the talk
Intro to GraphRAG: Build a Talent Assistant with Explicit Retrieval Logic
Build a skills graph in Neo4j, combine exact overlap with semantic similarity, extract people from biographies, and expose the resulting retrieval patterns to a LangGraph agent.
From a talk by Zach Blumenfeld
Before you start: Basic Python and familiarity with embeddings and LLM tool calling will help; the article introduces the Cypher patterns needed to follow the workshop.
From employee data to an assistant
How do you turn employee data into an assistant that can find skills, compare colleagues, and help assemble a team? The workshop begins with a prepared environment: participants receive numbered Post-it notes, connect to one of two Jupyter servers, and clone the workshop repository. Numbers at or below 160 use the first server; numbers at or above 201 use the second. The temporary login convention is lowercase attendee followed by the assigned number for both username and password. The slides and links are shared in the workshop GraphRAG intro Slack channel. These hosted environments last only for the session; the code remains available afterward.
Zach Blumenfeld develops the assistant in three stages: graph basics and Cypher in Neo4j, entity extraction from unstructured text, and a simple LangGraph agent with retrieval tools. This is an introductory workshop; advanced MCP integrations sit outside the main walkthrough. The prepared Jupyter servers already have the dependencies installed, while Neo4j Browser provides a separate view of the graph as it grows.
To follow the notebook setup:
- Open a terminal from Jupyter’s plus button and use the README’s clone command for the workshop repository.
- Copy
ws.envinto the Talent workshop directory. It supplies the database connection settings and the workshop OpenAI key. - Connect Neo4j Browser using the URI, username, and password from that environment file.
The notebooks execute queries; Browser makes the returned nodes and relationships easier to inspect.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Put domain logic in the graph
A knowledge assistant already has a user interface, models, an agent, and tools that can reach data sources. Adding a knowledge graph introduces a shared model for information arriving from both documents and PDFs and structured sources such as CSVs or relational tables. The reason to do this is practical: if you know the kinds of questions the assistant must answer, you can encode the relevant domain relationships before retrieval begins.
For the talent assistant, the starting relationship is a person knows a skill. Expose that schema and suitable tools to the agent, and questions about talent search, skills alignment, staffing, team formation, and substitutions can become explicit retrieval operations. The graph makes the definition of a match inspectable. This becomes especially useful when a request is broken into several steps: the agent can select operations whose retrieval logic is already defined.
Neo4j represents this information as a property graph. Nodes identify people, places, or things; relationships connect them; properties describe either. A person’s name is a property, but so could be a number, an array, or an embedding vector. Cypher expresses the connections with patterns that resemble the graph itself:
cypher
MATCH (p:Person)-[:KNOWS]->(s:Skill)
RETURN p.name AS person, s.name AS skill;
Person and Skill are node labels, KNOWS is a relationship type, and p and s are variables referring to the matched nodes. The arrow makes the relationship direction explicit. This is enough syntax to read the workshop’s examples without treating the session as a complete Cypher course.
Graph traversal also works alongside search and analytics. Embeddings represent inputs numerically so that related meanings can be retrieved even when the words differ. Neo4j supports range indexes, uniqueness constraints, text search, Lucene full-text search, and approximate nearest-neighbor vector search. Vector search can provide an entry point into a graph, after which Cypher follows relationships. Graph analytics adds centrality, community detection, paths, and graph embeddings; later, the assistant will use skill communities written back into the database.
How large should that graph be? Blumenfeld recommends keeping the data model small where possible, especially for dynamic query generation. At the same time, data points that need low-latency traversal between them belong in the same graph. Graph boundaries and schema complexity are therefore separate decisions: colocate connected information, then choose labels and properties that keep its model understandable.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Load people, skills, and stable identities
The first notebook is in the Talent workshop directory, rather than the repository’s other workshop. After loading ws.env, it reads a table with three fields: an email address, a name, and a list of skills. The dataframe is organized into batches for loading. A fresh participant database should be empty; Blumenfeld’s already contains data from an earlier run.
Before loading, establish the identities used to match existing nodes: email for a person and name for a skill. The supporting constraints matter both for correctness and for indexed matching during repeated merges. There is one important distinction in Neo4j’s constraint semantics: uniqueness alone permits a missing property; a node key combines uniqueness and existence, while a separate existence constraint can also require the property. The intended person identity here is an email that is both present and unique.
The load operation follows the same pattern for every input row. Merge the person by email, update the name, unpack the skills list, merge each skill by name, and merge the connection:
cypher
UNWIND $rows AS row
MERGE (p:Person {email: row.email})
SET p.name = row.name
WITH p, row
UNWIND row.skills AS skill_name
MERGE (s:Skill {name: skill_name})
MERGE (p)-[:KNOWS]->(s);
$rows is a batch of records with email, name, and skills fields. Matching the person on email rather than on every attribute allows a name update to affect the existing identity. Matching skills by name lets many people connect to the same skill node.
Browser can now inspect people, skills, or complete paths. Returning a path exposes the connections directly:
cypher
MATCH path = (:Person)-[:KNOWS]->(:Skill)
RETURN path
LIMIT 25;
The demonstration shows skills including API design, Tableau, and Flask attached to people. KNOWS is not a built-in Neo4j concept: it is the domain’s chosen relationship name, and HAS could have served the same purpose. That choice becomes part of the model’s vocabulary when an LLM reads the schema and generates queries.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Expand from Lucy to a local skills neighborhood
Start with an organization-wide aggregation: how many distinct people know each skill?
cypher
MATCH (p:Person)-[:KNOWS]->(s:Skill)
RETURN s.name AS skill, count(DISTINCT p) AS people
ORDER BY people DESC;
This ranks skills by the people connected to them. It answers a different question from semantic search: popularity comes from recorded relationships, not proximity between embeddings.
Next, choose Lucy and ask who shares her skills. The first traversal goes from Lucy to a skill and then back to another person who knows it. Extend that path once more to retrieve every skill those other people know:
cypher
MATCH path =
(lucy:Person {name: $person_name})-[:KNOWS]->(:Skill)
<-[:KNOWS]-(other:Person)-[:KNOWS]->(:Skill)
WHERE other <> lucy
RETURN path;
Set $person_name to Lucy’s stored name. The progression is Lucy → her skills → people sharing those skills → those people’s skills. It produces a larger neighborhood than the initial shared-skill query, because the final skills need not be ones Lucy knows.
The distinction is useful for team formation: a similar colleague can also bring capabilities beyond the original person’s skill set. In the displayed neighborhood, Scrum appears central because many of the connected people know it. The graph gives a concrete, symbolic definition of the group being examined, and that definition can be changed one traversal at a time.
The result shape controls how Browser can display it. Return nodes or a path to obtain a graph view; return only scalar values such as names and the result is a table. Browser’s table and graph tabs cannot reconstruct a path that the query did not return.
DISTINCT is a separate concern. It removes duplicate returned values, which can arise when several traversal routes reach the same result. It may be unnecessary for the particular path query shown, but becomes important when a multi-hop query projects repeated people or skill names into a list.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Decide how much Cypher the model should write
An audience question introduces the next design decision: how well do LLMs write these queries? Blumenfeld’s answer depends on schema complexity. Simple aggregations and carefully prompted paths over small schemas can work well. For more complicated traversal logic, he recommends limiting what the model must invent:
- Expert tools: Put a tested traversal in a Python function or an MCP tool and let the model choose it.
- Restricted patterns: Let the model select or fill in a small set of permitted query patterns.
- Dynamic generation: Supply enough schema and prompt context for the model to compose the query when the task warrants that flexibility.
He also mentions Gemma-derived Text2Cypher models available on Hugging Face. Neo4j’s April 2025 announcement describes that release family; the workshop itself uses OpenAI models.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Count shared skills, then decide whether to store the result
The Lucy traversal becomes a reusable similarity query when the starting person is parameterized and the shared skills are counted. DISTINCT now ensures that repeated routes do not produce repeated projected values:
cypher
MATCH (person:Person {email: $email})-[:KNOWS]->(skill:Skill)
<-[:KNOWS]-(other:Person)
WHERE other <> person
RETURN other.email AS email,
other.name AS name,
count(DISTINCT skill) AS shared_skills,
collect(DISTINCT skill.name) AS skills
ORDER BY shared_skills DESC;
The notebook also examines shared-skill counts across pairs of people. This similarity measure is grounded in exact common skills: its explanation is the list of shared nodes.
If the assistant repeatedly asks for people with similar skill sets, the overlap results can be stored as person-to-person relationships. The notebook uses the locally retrieved dataframe to merge these derived edges and attach overlap information. The graph can then follow one similarity edge instead of recomputing the original person–skill–person traversal on every request.
That shortcut introduces a refresh obligation. An audience member points out that the stored overlap is static, and Blumenfeld confirms that the query must run again to update it.
| Approach | Retrieval | Maintenance |
|---|---|---|
| Live shared-skill traversal | Compute overlap from current connections | No stored overlap to refresh |
| Stored similarity relationship | Read a derived person-to-person edge | Recompute after relevant updates |
A frequently changing graph may be better served by the live traversal. The database is designed to traverse relationships; materialization is an option, not a prerequisite.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Use Leiden to make skill communities explicit
The next enrichment operates over the person-to-person similarity graph. A Graph Data Science client creates a projection, and Leiden community detection finds groups of connected people. The objective is to identify communities with relatively dense internal connections and sparse connections between groups. Blumenfeld introduces the result as a hierarchy; algorithmically, Leiden refines communities and recursively aggregates them into condensed graphs. Here, the similarity edges give that clustering a skills-based interpretation.
The notebook writes community IDs back to the graph, checks the communities, and plots a heat map of skill frequency by community. The data is randomly generated, so the displayed groupings should not be read as real organizational structure. With realistic employee data, the same analysis could reveal concentrations of data-engineering, front-end, or machine-learning skills. Running the enrichment cells through G.drop completes this part of the notebook while retaining the written community properties needed by later examples.
Whether this work is worth doing depends on the questions users ask. Recurring questions about employee segments or performance within groups can justify persistent communities; similar reasoning applies to customer segmentation and recommendations. A request to find one person with skills similar to another may need only pairwise matching. The benefit of clustering is an inspectable grouping method that the assistant can describe, rather than asking the model to invent groups on demand.
The heat map explains the composition of those groups. Its cells show how often a skill appears within a community, not a separate set of secondary skills. An audience member suggests using the distribution to name communities, which is exactly the kind of interpretation it supports. Tableau or Swift appear in the discussion of one displayed group; product-manager, front-end, and DevOps communities are examples of what more realistic data might reveal.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Make the schema readable to an agent
A schema for an agent benefits from ordinary graph modeling discipline and language that expresses the domain clearly. Person → KNOWS → Skill reads naturally and translates directly into a traversal. Blumenfeld favors simpler models for dynamic query generation, while noting that retriever design, data size, and category cardinality still affect the right representation. Avoid turning every category into a label: hundreds or thousands of labels can make the schema harder to work with when properties would suffice.
Schema context can be assembled by retrieving node labels and relationship types into a JSON-like representation. For a reasonably stable model, enrich that representation with descriptions of properties, labels, and relationships, including guidance on where information belongs and how to retrieve it. Include actual patterns such as (p:Person)-[:KNOWS]->(s:Skill) alongside the descriptions. The model then receives both a vocabulary and examples of how its terms connect.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Embed descriptions, then make similarity traversable
Exact overlap is only one way skills can be related. The next dataset contains skill names, descriptions, and precomputed embeddings. The descriptions are the useful embedding input: short names such as R and AWS provide less context than an explanation of the capability they represent. Blumenfeld describes the workshop’s text-embedding-ada vectors as having 1,536 dimensions. He then loads the vectors and descriptions onto skill nodes and creates the skills vector index.
Searching from the stored Python skill retrieves ten relevant skills. The displayed results include Ruby, Java, pandas, Django, and PyTorch, with Blumenfeld acknowledging that some are more useful matches than others. The next example starts with wording absent from the database: LangChain embeds API coding, and a thresholded vector search returns API design and JavaScript. Semantic search therefore connects a user’s vocabulary to the graph’s existing skill entities.
The notebook then stores semantic similarity as scored relationships between skills. This makes the embedding-derived connections visible and traversable. The displayed groups include Azure, AWS, and cloud architecture; Flask and Django; Tableau, Power BI, and data visualization; Java, Scala, and Kotlin; and Python with pandas. These connections can support clustering and customized retrieval scores as well as visualization.
Persisted edges also allow explicit curation. Blumenfeld proposes removing a Java–Python connection if it is inappropriate for the application. Subsequent retrieval that follows those stored relationships can respect that decision. This changes the graph’s traversal policy; it does not require treating the embedding’s original notion of similarity as the final authority.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Combine exact evidence with semantic expansion
Semantic edges become more useful when combined with exact shared-skill evidence. A custom person-similarity query can weight these contributions separately: recorded overlap provides direct matches, while semantic connections recognize related capabilities. The application can expose this as several operations—find similar skills, then find people—or couple the logic in one query when a particular similarity policy should remain fixed.
An audience member asks how a natural-language request chooses the right entities and retrieval path within a larger ontology. Blumenfeld defers the concrete answer to the agent module, where the tools define those entry points. For now, the notebook’s larger query demonstrates the retrieval policy itself: balance semantic proximity against hard skill overlap to control which people count as similar.
The vector index does not have to live in Neo4j. An existing Postgres deployment or specialized vector database can remain part of the architecture.
| Vector location | Main consideration |
|---|---|
| Neo4j alongside the graph | Avoid cross-store synchronization |
| Existing external store | Avoid an unnecessary migration |
Blumenfeld suggests that colocation could theoretically reduce query latency, but supplies no measured comparison. The deployment decision depends on infrastructure-specific cost per performance.
The final graph-basics demonstration controls how far semantic expansion may go. A variable-length Cypher pattern permits zero to two semantic hops between skills, and the query also unions in the direct shared-skill pattern. Matthew knows React; John knows HTML; JavaScript provides a semantic bridge between those skills. Other connections need only one hop. Bounding the traversal makes the breadth of similarity explicit: a person can match through the same skill, a directly related skill, or a related skill reached through an intermediate node.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Turn biographies into the same graph model
The second module replaces the structured input table with two biographies. It reconnects through the same environment file and checks the database before extraction. At the start of module two, the workshop connection check expects 154 nodes after the preceding setup. The extraction target remains the same simple domain: people with names, emails, and named skills.
Pydantic expresses that target as nested Python objects:
python
from pydantic import BaseModel, Field
class Skill(BaseModel):
name: str
class Person(BaseModel):
name: str
email: str
skills: list[Skill] = Field(default_factory=list)
class PersonList(BaseModel):
people: list[Person] = Field(default_factory=list)
A richer model could put proficiency on the KNOWS relationship. In that case, the person would contain relationship objects, each holding both the skill and the relationship’s attributes. The workshop keeps only the list of skills.
A system prompt and GPT-4.1 turn the two documents into JSON for the two people. Once those objects exist, loading them is almost the same operation as before: merge each person on the indexed email, set the name, merge the skills, and merge the KNOWS relationships. Browser then confirms that the Neo4j employee examples and their extracted skills are present in the graph.
For more elaborate ingestion, Blumenfeld points to Neo4j GraphRAG for Python and the Knowledge Graph Builder reference application. These provide directions for workflows involving overlapping document chunks and concurrent or asynchronous processing, beyond a serial loop over short biographies.
Adding the biographies exposes the maintenance cost of earlier enrichment. The newly inserted KNOWS relationships are unweighted, but they can change person-to-person overlaps and community assignments. New skills also need semantic enrichment and corresponding semantic relationships. Blumenfeld confirms that these derived structures should be refreshed as data arrives, and acknowledges that ingestion would ideally precede clustering in the pipeline.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Keep document structure when context crosses sections
Extracting people and skills is one way to build a graph from text. Another is to preserve the document’s structure. Catalogs and RFPs often contain predictable sections: an introduction, objectives, a proposal, and subsections. Those structural pieces and their metadata can become graph objects, with embeddings attached to the relevant content. Retrieval can then move from an entity in a chunk up through the document hierarchy and down to another section.
This gives the system two sources of grouping. Leiden derives communities from connectivity; a document already supplies a natural hierarchy. That hierarchy can support summaries across sections or documents without requiring every grouping to be discovered algorithmically.
Why put entities, chunks, and documents in the same graph? Consider a legal clause whose expiry date appears elsewhere in the contract. Finding the clause is only the first retrieval step; following the document’s connections can bring back the date needed to interpret it. The accompanying domain-knowledge slide shows a graph and a query connecting chunks to definitions, another way to retrieve context through explicit relationships.
The audience extends this example to comparing data-protection language across vendors. Once the matching clauses are found, a traversal can also recover dates or perpetuity provisions elsewhere in the documents. Combining the entity and document structures preserves a route from a relevant passage to the surrounding facts that qualify its meaning.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Package the graph patterns as four expert tools
The third module reconnects to Neo4j and assembles four capabilities: retrieve a person’s skills, find similar skills, find similar people, and recommend people from a requested skill set. It begins with explicit graph patterns; schema-guided text-to-Cypher comes later. A direct person-to-skill match supplies the simplest tool.
The next patterns handle user wording that does not exactly match stored skill names. Vector search resolves phrases to skill nodes, and semantic relationships expand the candidates, with score thresholds controlling what is retained. Continuous delivery, cloud native, and security illustrate inputs that can be mapped into the graph’s vocabulary. This answers part of the earlier entry-point question: the tool’s retrieval pattern determines how the user’s words become graph entities.
Person similarity can use several kinds of evidence:
- Community membership: Retrieve people in the same Leiden skills community and inspect their skills.
- Exact overlap: Follow the stored similar-skill-set relationship. John Garcia is the example used to inspect overlap counts against other people.
- Exact plus semantic similarity: Combine shared-skill evidence with related skills, independently weighting the contributions. This yields a floating-point score and can change the ranking.
For recommendations from a requested skill set, the larger query is shown in two parts: search for skills, expand to semantically related skills, then find people who know them and count their matches.
A skills object helps define function arguments and return values. The final tool boundaries preserve the retrieval decisions:
| Tool | Graph operation |
|---|---|
| Retrieve a person’s skills | Match the person’s KNOWS connections |
| Find similar skills | Vector search, then one semantic hop |
| Find similar people | Weight exact and semantic skill similarity |
| Find people from skills | Resolve skills, expand, traverse to people, rank |
The last tool counts who knows the most matching skills. The model can choose the capability without having to reconstruct its traversal and scoring policy each time.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Let the agent choose and chain the tools
After testing the LLM and collecting the tools, the notebook first shows that tools can be bound directly to a model. It then creates a ReAct agent with LangGraph’s historical create_react_agent entry point, supplying the LLM and the four tools. LangGraph v1 later deprecated this helper in favor of LangChain’s create_agent; the recorded walkthrough uses the earlier API.
A greeting checks that the agent responds, and a notebook utility makes streamed interactions easier to inspect. Asking for Christophe’s skills selects the retrieve-person-skills tool. Asking which skills are similar to Power BI and data visualization selects the similar-skills tool. A question about a person with similar skills selects person similarity. These examples show the division of responsibility: the agent interprets the request and selects a capability; the tool supplies the defined graph retrieval.
A small Gradio chatbot then turns the isolated questions into a conversation. The sequence begins with skills related to Power BI, follows with who knows those skills, and then asks who is similar to those people. Blumenfeld checks the notebook trace as he proceeds: it shows a similar-skills call, a people-from-skills call, and finally person-similarity calls for each returned person. The follow-ups move between retrieval patterns while retaining the subject established by earlier answers.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Extend the assistant with annotated-schema queries
The final implementation example adds dynamic query generation to the expert-tool approach. An annotated schema supplies descriptions to an aggregation-query function containing an LLM. For a request to describe communities, the shown example generates Cypher that combines the person–skill pattern with the Leiden community property. The schema tells the model both what information exists and how the relevant parts connect. This receives a brief walkthrough, with additional examples left in the notebook.
The closing access question returns to the environment’s lifetime. The Jupyter servers are scheduled to shut down quickly, but the code and data remain in GitHub, linked from the deck shared through Slack. Blumenfeld suggests creating a cloud database through the Aura console’s free trial and loading the workshop data there to continue experimenting.
For participants at the event, the meetup announcement is corrected from tomorrow to that evening at 5:00. A separate 1:00 workshop goes deeper into graph analytics and communities, while booth follow-ups cover Neo4j MCP servers, ADK examples, and further knowledge-graph construction. Those extensions build on the working assistant demonstrated here: a small domain graph, explicit retrieval patterns, and an agent that can select among them.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Resources
From the talk
Three notebooks, data and slides covering graph basics, unstructured extraction and a talent retrieval agent.
Neo4j’s Leiden algorithm, configuration options and examples for writing community assignments.
Announcement of Neo4j’s Gemma 3 4B and 27B query-generation models, with links to the models and training dataset.
Python tools and examples for knowledge graph construction, vector retrieval and grounded generation.
An application for extracting graphs from unstructured sources, inspecting them and asking questions over the results.
Further reading
Examples explaining uniqueness, mandatory properties and node keys.
Original announcement describing text-embedding-ada-002 and its embedding dimensions.
Updates since the talk
Explains the move from the prebuilt create_react_agent function to LangChain’s create_agent.
Read the transcript and explore slides
Loading transcript and slides…