Tutorial Examples Knowledge Graph LLM Structured Extraction ~4 min read

Build a product recommendation engine with LLM + Neo4j

An LLM extracts each product's taxonomy and complements; CocoIndex turns the labels into a Neo4j knowledge graph that answers 'bought this, also need…'.


Updated Jul 10, 2026

A pile of product listings has recommendations hiding in plain sight: a pen pairs with ink refills and a notebook; a monitor pairs with a stand and an HDMI cable. That knowledge is locked in the prose of each listing. This post builds a recommendation engine from it: an LLM pulls the knowledge out, and a Neo4j graph connects it.

For each product, CocoIndex v1 asks an LLM two things: what the product is (its taxonomy), and what a buyer might also need (its complementary taxonomy). Those labels become nodes and edges in a Neo4j graph, and the recommendation falls out of the graph: a product whose “also need” matches another product’s “is” is the thing to cross-sell.

The full code is in the CocoIndex v1 repo.

The data model

Two node types and two edge types are enough:

  • Product nodes, one per listing, keyed by id (the filename), carrying title and price.
  • Taxonomy nodes, one per distinct label (gel pen, notebook, ink refill), keyed by value and shared across products.
  • PRODUCT_TAXONOMY edges, Product → Taxonomy: what the product is.
  • PRODUCT_COMPLEMENTARY_TAXONOMY edges, Product → Taxonomy: what pairs with it.

The nodes are plain dataclasses:

python
from dataclasses import dataclass

@dataclass
class Product:
    id: str      # primary key (the filename stem)
    title: str
    price: float

@dataclass
class Taxonomy:
    value: str   # primary key (the taxonomy label)

The flow

Because taxonomy labels are shared across products, the pipeline runs in two phases: per-product extraction declares each Product node and carries its labels forward, then a single graph-building pass declares the deduplicated Taxonomy nodes and all the edges.

Two-phase product-recommendation flow: phase one reads each product JSON, declares a Product node, and extracts its taxonomy labels with an LLM; phase two collects all labels, declares the deduplicated Taxonomy nodes, and declares the PRODUCT_TAXONOMY and PRODUCT_COMPLEMENTARY_TAXONOMY edges into Neo4j.

Connect Neo4j

The Neo4j connection is provided once in the app lifespan and read anywhere with a ContextKey. The LLM model name is a context value too, declared with detect_change=True so swapping models re-extracts everything against the new one:

python
import cocoindex as coco
from cocoindex.connectors import neo4j

KG_DB = coco.ContextKey[neo4j.ConnectionFactory]("kg_db")
LLM_MODEL = coco.ContextKey[str]("llm_model", 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["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]),
        ),
    )
    builder.provide(LLM_MODEL, os.environ.get("LLM_MODEL", "openai/gpt-4.1"))
    yield

Extract the taxonomy

The extraction schema is the whole prompt. A Pydantic model asks for two lists, and the field descriptions tell the model exactly what a good label looks like: a concise noun, US English, specific rather than broad (gel pen, not office supplies):

python
import pydantic

class ProductTaxonomy(pydantic.BaseModel):
    name: str = pydantic.Field(
        description="A concise noun for the product's core function, no branding "
        "or style. Common US English, lowercase. Prefer specific ('pen') over "
        "broad ('office supplies')."
    )

class ProductTaxonomyInfo(pydantic.BaseModel):
    taxonomies: list[ProductTaxonomy] = pydantic.Field(
        description="Taxonomies describing what this product is."
    )
    complementary_taxonomies: list[ProductTaxonomy] = pydantic.Field(
        description="Taxonomies for complementary products a buyer might also need."
    )

Extraction is one CocoIndex function that calls the model through instructor over LiteLLM, forcing the response to match the schema. memo=True caches the result by content, so an unchanged product is never sent to the model twice:

python
@coco.fn(memo=True)
async def extract_taxonomy(detail: str) -> ProductTaxonomyInfo:
    client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.JSON)
    result = await client.chat.completions.create(
        model=coco.use_context(LLM_MODEL),
        response_model=ProductTaxonomyInfo,
        messages=[
            {"role": "system", "content": TAXONOMY_PROMPT},
            {"role": "user", "content": detail},
        ],
    )
    return ProductTaxonomyInfo.model_validate(result.model_dump())

For the gel pen, the model returns gel pen as the taxonomy, and notebook, pen refill, and the like as complementary taxonomies:

The extract_taxonomy function reads the gel pen's product detail and returns a typed ProductTaxonomyInfo: taxonomies (gel pen) and complementary_taxonomies (notebook, pen refill, highlighter, sticky notes).

Phase 1: one Product node per file

process_file handles a single product: read the JSON, declare its Product node into the graph, extract the taxonomy, and return the labels for phase two. declare_record writes one node; CocoIndex reconciles it into Neo4j by the primary key:

python
@coco.fn(memo=True)
async def process_file(
    file: FileLike,
    product_table: neo4j.TableTarget[Product],
) -> ProductTaxonomies:
    raw = json.loads(await file.read_text())
    product_id = file.file_path.path.name.removesuffix(".json")
    price = float(str(raw["price"]).lstrip("$").replace(",", ""))

    product_table.declare_record(row=Product(id=product_id, title=raw["title"], price=price))

    info = await extract_taxonomy(PRODUCT_TEMPLATE.render(**raw))
    return ProductTaxonomies(
        product_id=product_id,
        taxonomies=[t.name for t in info.taxonomies],
        complementary=[t.name for t in info.complementary_taxonomies],
    )

Each declared Product becomes a node with id as its key and every field as a property:

Each product record becomes one Product node in Neo4j via declare_record: id is the primary key and every field (title, price) is carried across as a node property.

Phase 2: shared Taxonomy nodes and the edges

Taxonomy labels repeat across products, so one pass owns them. It gathers every label, declares the Taxonomy nodes, and declares the edges. Because a node is keyed by value, declaring gel pen from ten different products still produces exactly one node: CocoIndex deduplicates by the primary key.

Node deduplication by primary key: taxonomy labels collected from many products, where gel pen and notebook each appear more than once, collapse on their key (value) into one Neo4j Taxonomy node each.

declare_relation connects a product to a taxonomy by their keys. Two relation targets, one per edge type, share the same Product and Taxonomy node tables:

python
@coco.fn
async def build_graph(
    products: list[ProductTaxonomies],
    taxonomy_table: neo4j.TableTarget[Taxonomy],
    product_taxonomy_rel: neo4j.RelationTarget[Any],
    complementary_rel: neo4j.RelationTarget[Any],
) -> None:
    labels = {t for p in products for t in (*p.taxonomies, *p.complementary)}
    for value in labels:
        taxonomy_table.declare_record(row=Taxonomy(value=value))

    for p in products:
        for t in set(p.taxonomies):
            product_taxonomy_rel.declare_relation(from_id=p.product_id, to_id=t)
        for t in set(p.complementary):
            complementary_rel.declare_relation(from_id=p.product_id, to_id=t)

The PRODUCT_TAXONOMY edges link each product to what it is:

Each Product links to its own Taxonomy by a PRODUCT_TAXONOMY edge: Pink Gel Pen to GelPen, Premium Spiral Notepad to Notepad.

The PRODUCT_COMPLEMENTARY_TAXONOMY edges add what pairs with it. These are the edges that cross between products and make recommendations possible:

On top of the solid taxonomy edges, each Product gains a dashed PRODUCT_COMPLEMENTARY_TAXONOMY edge crossing to a buy-together Taxonomy: Pink Gel Pen to Notepad, Premium Spiral Notepad to GelPen.

Wire up the app

app_main mounts the two node tables and the two relation targets, walks the product folder, fans out one process_file per product, then runs the single build_graph pass over the collected labels:

python
@coco.fn
async def app_main(sourcedir: pathlib.Path) -> None:
    product_table = await neo4j.mount_table_target(
        KG_DB, "Product",
        await neo4j.TableSchema.from_class(Product, primary_key="id"),
        primary_key="id",
    )
    taxonomy_table = await neo4j.mount_table_target(
        KG_DB, "Taxonomy",
        await neo4j.TableSchema.from_class(Taxonomy, primary_key="value"),
        primary_key="value",
    )
    product_taxonomy_rel = await neo4j.mount_relation_target(
        KG_DB, "PRODUCT_TAXONOMY", product_table, taxonomy_table
    )
    complementary_rel = await neo4j.mount_relation_target(
        KG_DB, "PRODUCT_COMPLEMENTARY_TAXONOMY", product_table, taxonomy_table
    )

    files = localfs.walk_dir(
        sourcedir, recursive=True,
        path_matcher=PatternFilePathMatcher(included_patterns=["**/*.json"]),
    )
    products = list(await asyncio.gather(*[
        coco.use_mount(coco.component_subpath("file", key), process_file, file, product_table)
        async for key, file in files.items()
    ]))

    await coco.mount(
        coco.component_subpath("build_graph"),
        build_graph, products, taxonomy_table, product_taxonomy_rel, complementary_rel,
    )


app = coco.App(coco.AppConfig(name="ProductRecommendation"), app_main,
               sourcedir=pathlib.Path("./products"))

Run it

Start Neo4j, install the example, and build the graph. The example ships a products/ folder of sample listings:

sh
docker run -d -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/cocoindex neo4j:5.26-community
pip install "cocoindex[neo4j]" instructor litellm pydantic jinja2
cocoindex update main

On the nine sample products that is nine Product nodes, around forty Taxonomy nodes, and both edge types wired up. Editing one product re-extracts only that product, then the graph diffs: new nodes and edges are added, and ones no longer supported anywhere are removed.

Query the recommendations

Open Neo4j Browser (neo4j / cocoindex) and let the graph do the recommending. To find products to pair with a gel pen, walk from the pen’s complementary taxonomy to any product that is that taxonomy:

cypher
MATCH (:Taxonomy {value: "gel pen"})<-[:PRODUCT_TAXONOMY]-(:Product)
      -[:PRODUCT_COMPLEMENTARY_TAXONOMY]->(need:Taxonomy)
MATCH (rec:Product)-[:PRODUCT_TAXONOMY]->(need)
RETURN DISTINCT rec.title

On the sample data, recommending for a pen surfaces the notepad and the multipurpose paper, the cross-sell you would want. To see the whole graph, MATCH p=()-->() RETURN p:

The product recommendation knowledge graph in Neo4j Browser: green Product nodes connected to purple Taxonomy nodes by PRODUCT_TAXONOMY and PRODUCT_COMPLEMENTARY_TAXONOMY edges.

Support us

We are constantly adding examples and improving the runtime. If this was helpful, please star CocoIndex on GitHub.

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

How do I build a product recommendation engine with an LLM and a graph database?

For each product, use an LLM to extract two things: what the product is (its taxonomy) and what a buyer might also need (its complementary taxonomy). Declare those as Product and Taxonomy nodes plus PRODUCT_TAXONOMY and PRODUCT_COMPLEMENTARY_TAXONOMY edges in Neo4j. A product whose complementary taxonomy matches another product's is-a taxonomy is the thing to recommend. See The data model.

What is a product taxonomy in this example?

A concise noun (or short noun phrase) for a product's core function, without branding or style, in common US English, lowercase, and specific rather than broad (gel pen, not office supplies). A product can have several. Taxonomy labels are shared across products, so gel pen is one node every relevant product points at. See The data model.

How do I use an LLM to extract product taxonomy in CocoIndex v1?

Describe the output as a Pydantic model (ProductTaxonomyInfo with taxonomies and complementary_taxonomies), and let the field descriptions carry the instructions. A single @coco.fn(memo=True) function calls the model through instructor over LiteLLM, forcing the response to match the schema. memo=True caches by content, so an unchanged product is never re-sent. See Extract the taxonomy.

How do I find complementary products customers buy together?

Ask the LLM, as part of extraction, for the complementary_taxonomies a buyer of this product might also need. For a gel pen that yields notebook, pen refill, and the like. Those become PRODUCT_COMPLEMENTARY_TAXONOMY edges via declare_relation, and they are the edges that make cross-sell recommendations possible. See Phase 2: shared Taxonomy nodes and the edges.

How does CocoIndex v1 map nodes and relationships to Neo4j?

Mount a node table per label with neo4j.mount_table_target and write nodes with declare_record; mount a relation target per edge type with neo4j.mount_relation_target and write edges with declare_relation(from_id, to_id). Nodes are keyed by a primary key (id for Product, value for Taxonomy), so declaring the same label from many products deduplicates to one node. See Phase 1 and Phase 2.

What do I need to run the CocoIndex v1 product recommendation example?

Neo4j as the graph database and an OpenAI API key (or set LLM_MODEL to a local Ollama model). CocoIndex v1 keeps its own processing state in a local file, so there is no separate tracking database to run. Start Neo4j, pip install the example, and run cocoindex update main. See Run it.

How do I query the recommendations in the graph?

Open the Neo4j Browser at http://localhost:7474 (neo4j / cocoindex). To recommend products to pair with a gel pen, walk from the pen's complementary taxonomy to any product that is that taxonomy with a Cypher query; on the sample data that surfaces the notepad and multipurpose paper. Use MATCH p=()-->() RETURN p to see the whole graph. See Query the recommendations.