Graph-Powered Recommendations

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-09-30 against Neo4j 2026.09.0 with the Graph Data Science library, the Neo4j Java driver 6.3.0, Spring AI 2.0.1, LangChain4j 1.20.2, and langchain 1.4.3 with the neo4j Python driver 6.3.1 on Python 3.12 (see the version baseline). The scoring function was run on hand-written vectors; the Java classes were compiled against the released jars and the Python code checked against the released packages; the Cypher and GDS calls were not executed.

Retrieval answers the question a reader asked. Recommendations answer the question they did not ask yet: "you read the pgvector tuning page; the next thing people in your situation need is the reranking page". DocsAssistant already has the ingredients — page embeddings, a graph of components and concepts, and a log of which authenticated readers opened which pages — and a graph database is a natural place to combine them. The graph algorithms themselves are documented in Graph Data Science Fundamentals, Similarity, Embeddings and ML Pipelines and Community Detection Algorithms; this page assembles them into a recommender that uses a language model where it helps.

flowchart LR L[Reading events] --> CR[Co-reading graph: Page to Page weights] L --> S[LLM summary of a reader's recent pages] S --> RE[Reader embedding] P[Page embeddings] --> KNN[GDS KNN: SIMILAR edges] CR --> LV[GDS Louvain: page communities] RE --> SC[Blended score] KNN --> SC LV --> SC SC --> R[Top pages the reader may open and has not read]

The model

Four signals, each cheap to compute and each covering a weakness of the others:

Signal Computed from Covers

Content similarity (KNN)

Page embeddings; GDS KNN writes (:Page)-[:SIMILAR {score}]→(:Page).

New pages with no readers yet (cold start of items).

Co-reading communities (Louvain)

(:Page)-[:CO_READ {weight}]-(:Page) from sessions; GDS Louvain writes page.community.

Paths through the docs that content alone does not show (setup, then security, then deployment).

Reader intent (LLM summary)

A model summarises the reader’s last pages into one sentence of intent ("setting up hybrid search on pgvector for a Spring Boot service"), which is embedded into reader.embedding.

Readers whose history is noisy; turns a list of pages into a direction.

Access and freshness

Page ACL and updatedAt.

Never recommend what the reader cannot open; prefer current pages.

The blended score of page for reader , given the pages the reader has already read, is

𝟙

where is the reader embedding, the KNN edge score (0 when there is no edge), the Louvain community of and the communities of the pages already read. Weights such as are a starting point; tune them offline on held-out sessions (recall of the next page actually opened) and online with an A/B test.

Building the graph signals with GDS

Project the pages with their embeddings and the co-reading relationships, write KNN and Louvain results back, and drop the projection. Re-run nightly; both algorithms are fast at documentation scale:

Cypher — KNN over page embeddings and Louvain over co-reading (Neo4j GDS)
// Co-reading edges from reading sessions (maintained incrementally by the event consumer)
MATCH (s:Session)-[:VIEWED]->(a:Page), (s)-[:VIEWED]->(b:Page)
WHERE elementId(a) < elementId(b)
MERGE (a)-[r:CO_READ]-(b)
ON CREATE SET r.weight = 1 ON MATCH SET r.weight = r.weight + 1;

CALL gds.graph.project(
  'pages',
  {Page: {properties: ['embedding']}},
  {CO_READ: {orientation: 'UNDIRECTED', properties: ['weight']}}
);

CALL gds.knn.write('pages', {
  nodeProperties: {embedding: 'COSINE'},
  topK: 10,
  similarityCutoff: 0.75,
  writeRelationshipType: 'SIMILAR',
  writeProperty: 'score'
}) YIELD nodesCompared, relationshipsWritten;

CALL gds.louvain.write('pages', {
  relationshipWeightProperty: 'weight',
  writeProperty: 'community'
}) YIELD communityCount, modularity;

CALL gds.graph.drop('pages');

The co-reading MERGE above recomputes from all sessions and is only for illustration; in production the reading-event consumer increments weights per event, and sessions older than the retention period are removed together with their contribution (reading behaviour is personal data; see Access Control & Privacy).

Reader embeddings from an LLM summary: Spring AI

The reader’s intent is refreshed after every few page views. Spring AI summarises the recent pages with a ChatClient, embeds the summary with the same EmbeddingModel as the pages (so both live in one vector space), and writes it to the reader node:

ReaderProfileService.java — summarise, embed, store (Java, Spring AI 2.0.1, Neo4j Java driver)
package com.example.docsassistant.rag.recommend;

import java.util.List;
import java.util.Map;

import org.neo4j.driver.Driver;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;

@Service
public class ReaderProfileService {

    private static final String RECENT_PAGES = """
            MATCH (u:Reader {id: $readerId})-[v:VIEWED]->(p:Page)
            RETURN p.title AS title, p.summary AS summary ORDER BY v.at DESC LIMIT 10""";

    private static final String STORE = """
            MATCH (u:Reader {id: $readerId})
            SET u.intent = $intent, u.embedding = $embedding, u.profileUpdatedAt = datetime()""";

    private final ChatClient chatClient;
    private final EmbeddingModel embeddingModel;
    private final Driver driver;

    public ReaderProfileService(ChatClient.Builder builder, EmbeddingModel embeddingModel, Driver driver) {
        this.chatClient = builder.build();
        this.embeddingModel = embeddingModel;
        this.driver = driver;
    }

    public String refresh(String readerId) {
        List<String> pages = driver.executableQuery(RECENT_PAGES).withParameters(Map.of("readerId", readerId))
                .execute().records().stream()
                .map(r -> r.get("title").asString() + ": " + r.get("summary").asString(""))
                .toList();
        if (pages.isEmpty()) {
            return null;
        }
        String intent = chatClient.prompt()
                .system("In one sentence, describe what this reader is trying to achieve, based only on the pages "
                        + "they read. No names, no personal data.")
                .user(String.join("\n", pages))
                .call()
                .content();
        float[] vector = embeddingModel.embed(intent);
        driver.executableQuery(STORE)
                .withParameters(Map.of("readerId", readerId, "intent", intent, "embedding", vector))
                .execute();
        return intent;
    }
}

Scoring and ranking in Cypher

The blended score is one Cypher query: candidate pages the reader may open and has not read, scored with the reader embedding (vector.similarity.cosine), the best KNN edge from any page already read, and community overlap. The ACL list comes from the verified caller, exactly as for retrieval:

RecommendationQueries.java — the blended score in Cypher (Java, LangChain4j 1.20.2 service, Neo4j Java driver)
package com.example.docsassistant.l4j.recommend;

import java.util.List;
import java.util.Map;

import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import org.neo4j.driver.Driver;

public class RecommendationQueries {

    public record Recommendation(String pageId, String title, double score) {
    }

    private static final String RECOMMEND = """
            MATCH (u:Reader {id: $readerId})
            OPTIONAL MATCH (u)-[:VIEWED]->(h:Page)
            WITH u, collect(h) AS read, collect(DISTINCT h.community) AS readCommunities
            MATCH (p:Page) WHERE p.acl IN $readableAcls AND NOT p IN read AND p.deletedAt IS NULL
            OPTIONAL MATCH (h2:Page)-[k:SIMILAR]->(p) WHERE h2 IN read
            WITH p, u, readCommunities, max(coalesce(k.score, 0.0)) AS knn
            WITH p, knn,
                 CASE WHEN u.embedding IS NULL THEN 0.0 ELSE vector.similarity.cosine(u.embedding, p.embedding) END AS intent,
                 CASE WHEN p.community IN readCommunities THEN 1.0 ELSE 0.0 END AS community
            RETURN p.id AS pageId, p.title AS title, $alpha * intent + $beta * knn + $gamma * community AS score
            ORDER BY score DESC LIMIT $limit""";

    private final Driver driver;
    private final ChatModel chatModel;
    private final EmbeddingModel embeddingModel;

    public RecommendationQueries(Driver driver, ChatModel chatModel, EmbeddingModel embeddingModel) {
        this.driver = driver;
        this.chatModel = chatModel;
        this.embeddingModel = embeddingModel;
    }

    /** LangChain4j variant of the reader-intent refresh: summarise with the ChatModel, embed with the EmbeddingModel. */
    public float[] intentEmbedding(List<String> recentPageTitles) {
        String intent = chatModel.chat("In one sentence, describe what this reader is trying to achieve, based only on "
                + "these pages. No names, no personal data.\n" + String.join("\n", recentPageTitles));
        return embeddingModel.embed(intent).content().vector();
    }

    public List<Recommendation> recommend(String readerId, List<String> readableAcls, int limit) {
        return driver.executableQuery(RECOMMEND)
                .withParameters(Map.of("readerId", readerId, "readableAcls", readableAcls, "limit", limit,
                        "alpha", 0.5, "beta", 0.3, "gamma", 0.2))
                .execute().records().stream()
                .map(r -> new Recommendation(r.get("pageId").asString(), r.get("title").asString(),
                        r.get("score").asDouble()))
                .toList();
    }
}

The candidate scan (MATCH (p:Page) WHERE …​) is fine for thousands of pages. For larger catalogues, start from a vector index query on the reader embedding to get a few hundred candidates, then add the graph terms.

The same pieces in Python

The Python version of the intent refresh and a check of the blended score, for offline tuning of the weights on logged sessions:

docsassistant/recommend/profile.py — intent summary and blended score (Python, langchain 1.4.3)
import math
import os

from langchain.chat_models import init_chat_model
from langchain.embeddings import init_embeddings

summariser = init_chat_model(os.environ["CHAT_MODEL"], temperature=0)
embeddings = init_embeddings(os.environ["EMBEDDING_MODEL"])


def reader_intent(recent_titles: list[str]) -> tuple[str, list[float]]:
    intent = summariser.invoke(
        "In one sentence, describe what this reader is trying to achieve, based only on these pages. "
        "No names, no personal data.\n" + "\n".join(recent_titles)).text
    return intent, embeddings.embed_query(intent)


def cosine(a: list[float], b: list[float]) -> float:
    return sum(x * y for x, y in zip(a, b)) / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))


def blended_score(reader_vec, page_vec, knn_scores_from_read: list[float], page_community: int,
                  read_communities: set[int], alpha: float = 0.5, beta: float = 0.3, gamma: float = 0.2) -> float:
    intent = cosine(reader_vec, page_vec) if reader_vec else 0.0
    knn = max(knn_scores_from_read, default=0.0)
    community = 1.0 if page_community in read_communities else 0.0
    return alpha * intent + beta * knn + gamma * community

Explaining and governing recommendations

  • Explain each recommendation with its strongest signal: "similar to PostgreSQL & pgvector", "often read next by people setting up hybrid search". Explanations build trust and expose bad signals quickly.

  • Show the intent, let the reader correct it. Displaying "you seem to be setting up hybrid search on pgvector" with an edit option improves the profile and makes the personalisation transparent.

  • Respect consent and retention. Reader profiles are derived personal data: make them opt-in or at least opt-out-able, never put personal data in the summary prompt, and delete the reader node and its VIEWED edges with the rest of the user’s data.

  • Evaluate offline on the next page actually opened (recall at 5) and online with an A/B test on click-through, using the same machinery as Evaluation at Scale.

The hybrid recommender — LLM-summarised behaviour embeddings combined with GDS KNN and Louvain — follows the concepts of Anthapu and Agarwal, Building Neo4j-Powered Applications with LLMs (Chapters 8 to 10), paraphrased and updated from the book’s LangChain4j 0.35 and pre-1.0 Spring AI code to the current APIs; see the bibliography.