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.
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 |
New pages with no readers yet (cold start of items). |
Co-reading communities (Louvain) |
|
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 |
Readers whose history is noisy; turns a list of pages into a direction. |
Access and freshness |
Page ACL and |
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:
// 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');
Source: GDS — K-Nearest Neighbors and GDS — Louvain.
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:
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:
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();
}
}
Source: LangChain4j — Chat and Language Models, LangChain4j — Embedding Model and Cypher Manual — Vector functions.
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:
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
Source: LangChain — Models and Neo4j Graph Data Science.
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
VIEWEDedges 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.