docs = new ArrayList<>();
+ for (Resource r : new PathMatchingResourcePatternResolver().getResources("classpath:corpus/*.txt")) {
+ String id = r.getFilename().replace(".txt", "");
+ String[] lines = new String(r.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip().split("\n", 2);
+ docs.add(new Doc(id, lines[0].strip(), lines[1].strip()));
+ }
+ docs.sort(Comparator.comparing(Doc::id));
+ return docs;
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityResolver.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityResolver.java
new file mode 100644
index 0000000..5532c48
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityResolver.java
@@ -0,0 +1,126 @@
+package com.ankurm.graphrag;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+
+/**
+ * Step 2: decide which names are the same thing. A model that reads each chunk on its own writes "Card
+ * Gateway" in one post-mortem and "Payments Gateway" in a service page; unless something merges them they
+ * become two nodes and every path through one is invisible from the other.
+ *
+ * Three layers, applied in order: names that differ only in case, punctuation or spacing are the same key;
+ * a hand-kept alias table; and, optionally, cosine similarity of the name embeddings between entities of the
+ * same type above a threshold.
+ */
+public final class EntityResolver {
+
+ /** Two distinct names of the same type and how close their embeddings are. */
+ public record Pair(String a, String b, EntityType type, double cosine) {
+ }
+
+ /** What every mention maps to, and what was merged on the way. */
+ public record Resolution(Map canonicalByName, Map typeByCanonical,
+ List merged) {
+ }
+
+ private final EmbeddingModel embeddings;
+
+ public EntityResolver(EmbeddingModel embeddings) {
+ this.embeddings = embeddings;
+ }
+
+ public static String key(String name) {
+ return name.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
+ }
+
+ /**
+ * @param mentions every entity mention from every chunk, repeats included (the most frequent spelling wins)
+ * @param aliases lower-case-insensitive alias to canonical name
+ * @param threshold merge same-type names whose cosine is at least this; pass a value above 1 to switch it off
+ */
+ public Resolution resolve(List mentions, Map aliases, double threshold) {
+ Map counts = new HashMap<>();
+ Map types = new LinkedHashMap<>();
+ Map spelling = new LinkedHashMap<>();
+ for (Extraction.Entity e : mentions) {
+ counts.merge(key(e.name()), 1, Integer::sum);
+ types.putIfAbsent(key(e.name()), e.type());
+ spelling.putIfAbsent(key(e.name()), e.name());
+ }
+ Map aliasByKey = new HashMap<>();
+ aliases.forEach((alias, canonical) -> aliasByKey.put(key(alias), canonical));
+
+ // key -> canonical key after the alias table
+ Map parent = new LinkedHashMap<>();
+ for (String k : types.keySet()) {
+ String canonical = aliasByKey.get(k);
+ parent.put(k, canonical == null ? k : key(canonical));
+ if (canonical != null) {
+ types.putIfAbsent(key(canonical), types.get(k));
+ spelling.putIfAbsent(key(canonical), canonical);
+ }
+ }
+
+ List merged = new ArrayList<>();
+ List roots = parent.values().stream().distinct().toList();
+ if (threshold <= 1.0) {
+ for (Pair p : similarPairs(roots, types, spelling)) {
+ if (p.cosine() >= threshold) {
+ String ka = key(p.a());
+ String kb = key(p.b());
+ String winner = counts.getOrDefault(ka, 0) >= counts.getOrDefault(kb, 0) ? ka : kb;
+ String loser = winner.equals(ka) ? kb : ka;
+ final String w = winner;
+ final String l = loser;
+ parent.replaceAll((k, v) -> v.equals(l) ? w : v);
+ merged.add(p);
+ }
+ }
+ }
+
+ Map canonicalByName = new LinkedHashMap<>();
+ Map typeByCanonical = new LinkedHashMap<>();
+ for (Extraction.Entity e : mentions) {
+ String root = parent.get(key(e.name()));
+ String canonical = spelling.get(root);
+ canonicalByName.put(e.name(), canonical);
+ typeByCanonical.putIfAbsent(canonical, types.get(root));
+ }
+ return new Resolution(canonicalByName, typeByCanonical, merged);
+ }
+
+ /** All pairs of distinct same-type names, most similar first. Used to merge and to print the evidence. */
+ public List similarPairs(List keys, Map types, Map spelling) {
+ List vectors = embeddings.embed(keys.stream().map(spelling::get).toList());
+ List out = new ArrayList<>();
+ for (int i = 0; i < keys.size(); i++) {
+ for (int j = i + 1; j < keys.size(); j++) {
+ if (types.get(keys.get(i)) == types.get(keys.get(j))) {
+ out.add(new Pair(spelling.get(keys.get(i)), spelling.get(keys.get(j)), types.get(keys.get(i)),
+ cosine(vectors.get(i), vectors.get(j))));
+ }
+ }
+ }
+ out.sort(Comparator.comparingDouble(Pair::cosine).reversed());
+ return out;
+ }
+
+ public static double cosine(float[] a, float[] b) {
+ double dot = 0;
+ double na = 0;
+ double nb = 0;
+ for (int i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ na += a[i] * a[i];
+ nb += b[i] * b[i];
+ }
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
+ }
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityType.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityType.java
new file mode 100644
index 0000000..0601355
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/EntityType.java
@@ -0,0 +1,6 @@
+package com.ankurm.graphrag;
+
+/** The node kinds the extraction schema allows. A closed set, so the model cannot invent "Concept" or "Thing". */
+public enum EntityType {
+ SYSTEM, TEAM, PERSON, VENDOR, INCIDENT, PLACE
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extraction.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extraction.java
new file mode 100644
index 0000000..2162cdf
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extraction.java
@@ -0,0 +1,20 @@
+package com.ankurm.graphrag;
+
+import java.util.List;
+
+/**
+ * What the model must return for one chunk. {@code ChatClient.entity(Extraction.class)} turns this record into a
+ * JSON schema, appends it to the prompt, and parses the reply back into the record.
+ */
+public record Extraction(List entities, List relations) {
+
+ public record Entity(String name, EntityType type) {
+ }
+
+ public record Relation(String source, RelationType type, String target) {
+ }
+
+ public static Extraction empty() {
+ return new Extraction(List.of(), List.of());
+ }
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extractor.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extractor.java
new file mode 100644
index 0000000..8ce4419
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/Extractor.java
@@ -0,0 +1,65 @@
+package com.ankurm.graphrag;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.model.ChatModel;
+
+/**
+ * Step 1 of GraphRAG: one chunk of text in, entities and relations out, through
+ * {@code ChatClient.entity(Extraction.class)}. The model sees the chunk and a JSON schema built from the
+ * {@link Extraction} record; the enums in that record are what constrain it to a fixed vocabulary.
+ */
+public class Extractor {
+
+ static final String SYSTEM = """
+ You turn short technical documents into a knowledge graph.
+ List every named system, team, person, vendor, incident and place the document mentions as an entity,
+ using the name exactly as the document writes it.
+ List a relation only when the document states it. Never infer one.
+ """;
+
+ static final String USER = """
+ Extract the entities and relations from the document.
+
+ DOCUMENT_START
+ {document}
+ DOCUMENT_END
+ """;
+
+ /** The cleaned extraction plus how many relations were dropped for naming an entity that was never listed. */
+ public record Result(Extraction extraction, int droppedRelations) {
+ }
+
+ private final ChatClient client;
+
+ public Extractor(ChatModel model) {
+ this.client = ChatClient.builder(model).defaultSystem(SYSTEM).build();
+ }
+
+ public Result extract(Corpus.Doc doc) {
+ Extraction raw = client.prompt()
+ .user(u -> u.text(USER).param("document", doc.text()))
+ .call()
+ .entity(Extraction.class);
+ return clean(raw);
+ }
+
+ /**
+ * A relation is kept only if both endpoints appear in the entity list. A model that writes a relation to a
+ * name it never listed would otherwise make the graph writer MATCH nothing and silently create no edge.
+ */
+ static Result clean(Extraction raw) {
+ if (raw == null || raw.entities() == null || raw.relations() == null) {
+ return new Result(Extraction.empty(), 0);
+ }
+ Set names = new HashSet<>();
+ raw.entities().forEach(e -> names.add(e.name()));
+ List kept = raw.relations().stream()
+ .filter(r -> names.contains(r.source()) && names.contains(r.target()))
+ .toList();
+ return new Result(new Extraction(raw.entities(), kept), raw.relations().size() - kept.size());
+ }
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphRetriever.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphRetriever.java
new file mode 100644
index 0000000..3e1bf12
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphRetriever.java
@@ -0,0 +1,101 @@
+package com.ankurm.graphrag;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.springframework.ai.embedding.EmbeddingModel;
+
+/**
+ * Multi-hop retrieval in three steps, with no language model anywhere in it:
+ *
+ * - Link: cut the question into every 1 to 4 word span, embed them, ask the entity vector index for
+ * the nearest entity of each, and keep the ones that clear {@link #linkThreshold}.
+ * - Walk: from each seed entity, collect every simple path of up to {@code depth} relationships.
+ * - Rank: render each path as a sentence, embed it, and keep the {@code topPaths} closest to the
+ * question. The chunks that stated the relationships on those paths are the context.
+ *
+ */
+public class GraphRetriever {
+
+ private static final Set STOP = Set.of("a", "an", "the", "of", "to", "in", "on", "is", "are", "who", "what",
+ "which", "where", "that", "does", "do", "for", "by", "if", "and", "or", "it", "its", "this", "these", "those",
+ "with", "from", "at", "as", "be", "goes", "down", "stop", "working", "whom", "behind");
+
+ public record Seed(String span, String name, double score) {
+ }
+
+ public record RankedPath(GraphStore.GraphPath path, double score) {
+ }
+
+ public record Retrieval(String question, List seeds, int candidatePaths, List selected,
+ Set chunkIds) {
+ }
+
+ private final GraphStore graph;
+ private final EmbeddingModel embeddings;
+ private final double linkThreshold;
+
+ public GraphRetriever(GraphStore graph, EmbeddingModel embeddings, double linkThreshold) {
+ this.graph = graph;
+ this.embeddings = embeddings;
+ this.linkThreshold = linkThreshold;
+ }
+
+ public List link(String question) {
+ String[] words = question.replaceAll("[?,.]", "").split("\\s+");
+ List spans = new ArrayList<>();
+ for (int i = 0; i < words.length; i++) {
+ for (int n = 1; n <= 4 && i + n <= words.length; n++) {
+ String first = words[i].toLowerCase();
+ String last = words[i + n - 1].toLowerCase();
+ if (STOP.contains(first) || STOP.contains(last)) {
+ continue;
+ }
+ spans.add(String.join(" ", java.util.Arrays.copyOfRange(words, i, i + n)));
+ }
+ }
+ List vectors = embeddings.embed(spans);
+ Map best = new LinkedHashMap<>();
+ for (int i = 0; i < spans.size(); i++) {
+ for (GraphStore.EntityHit hit : graph.nearestEntities(vectors.get(i), 1)) {
+ if (hit.score() >= linkThreshold) {
+ Seed seed = new Seed(spans.get(i), hit.name(), hit.score());
+ best.merge(hit.name(), seed, (a, b) -> a.score() >= b.score() ? a : b);
+ }
+ }
+ }
+ return best.values().stream().sorted(Comparator.comparingDouble(Seed::score).reversed()).toList();
+ }
+
+ public Retrieval retrieve(String question, int depth, int topPaths) {
+ List seeds = link(question);
+ // one walk per seed; a path found from two seeds in opposite directions counts once
+ Map, GraphStore.GraphPath> unique = new LinkedHashMap<>();
+ for (Seed s : seeds) {
+ for (GraphStore.GraphPath p : graph.paths(EntityResolver.key(s.name()), depth, 5000)) {
+ Set edgeSet = p.edges().stream().map(GraphStore.Edge::sentence).collect(Collectors.toSet());
+ unique.putIfAbsent(edgeSet, p);
+ }
+ }
+ List candidates = new ArrayList<>(unique.values());
+ List ranked = new ArrayList<>();
+ if (!candidates.isEmpty()) {
+ float[] q = embeddings.embed(question);
+ List vectors = embeddings.embed(candidates.stream().map(GraphStore.GraphPath::text).toList());
+ for (int i = 0; i < candidates.size(); i++) {
+ ranked.add(new RankedPath(candidates.get(i), EntityResolver.cosine(q, vectors.get(i))));
+ }
+ ranked.sort(Comparator.comparingDouble(RankedPath::score).reversed());
+ }
+ List selected = ranked.subList(0, Math.min(topPaths, ranked.size()));
+ Set chunks = new LinkedHashSet<>();
+ selected.forEach(rp -> rp.path().edges().forEach(e -> chunks.addAll(e.sources())));
+ return new Retrieval(question, seeds, candidates.size(), List.copyOf(selected), chunks);
+ }
+}
diff --git a/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphStore.java b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphStore.java
new file mode 100644
index 0000000..299851f
--- /dev/null
+++ b/graphrag-neo4j/src/main/java/com/ankurm/graphrag/GraphStore.java
@@ -0,0 +1,164 @@
+package com.ankurm.graphrag;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.Record;
+import org.neo4j.driver.Value;
+import org.springframework.ai.embedding.EmbeddingModel;
+
+/**
+ * Everything that talks Cypher. Entities are {@code (:Entity {key, name, type, embedding})}; each relation is a
+ * real typed relationship (:DEPENDS_ON, :OWNED_BY, ...) carrying the ids of the chunks that stated it in a
+ * {@code sources} list; chunks are {@code (:Chunk)} nodes written by Spring AI's Neo4jVectorStore, and a
+ * {@code (:Chunk)-[:MENTIONS]->(:Entity)} edge records where every entity was seen.
+ */
+public class GraphStore {
+
+ public static final String ENTITY_INDEX = "entity_name_embedding";
+
+ /** One stated relation, in its real direction, with the chunks that stated it. */
+ public record Edge(String from, RelationType type, String to, List sources) {
+ public String sentence() {
+ return from + " " + type.phrase() + " " + to;
+ }
+ }
+
+ /** A walk through the graph from a seed entity. {@code edges} are in walking order, each in its true direction. */
+ public record GraphPath(String seed, List edges) {
+ public String text() {
+ return edges.stream().map(Edge::sentence).collect(Collectors.joining("; "));
+ }
+ }
+
+ public record EntityHit(String name, String key, EntityType type, double score) {
+ }
+
+ private final Driver driver;
+ private final EmbeddingModel embeddings;
+
+ public GraphStore(Driver driver, EmbeddingModel embeddings) {
+ this.driver = driver;
+ this.embeddings = embeddings;
+ }
+
+ public void initSchema() {
+ run("CREATE CONSTRAINT entity_key IF NOT EXISTS FOR (e:Entity) REQUIRE e.key IS UNIQUE", Map.of());
+ run("CREATE VECTOR INDEX " + ENTITY_INDEX + " IF NOT EXISTS FOR (e:Entity) ON (e.embedding) "
+ + "OPTIONS {indexConfig: {`vector.dimensions`: " + embeddings.dimensions()
+ + ", `vector.similarity_function`: 'cosine'}}", Map.of());
+ run("CALL db.awaitIndexes(60)", Map.of());
+ }
+
+ public void clearEntities() {
+ run("MATCH (e:Entity) DETACH DELETE e", Map.of());
+ }
+
+ /**
+ * Writes one resolved extraction per chunk. Relationship types are concatenated into the Cypher text because
+ * Cypher has no parameter for them; {@link RelationType} is the allow-list that makes that safe.
+ */
+ public void write(Map extractionByChunk, EntityResolver.Resolution resolution) {
+ List