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)); } }