c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
"""Cluster node embeddings and precompute nearest neighbors for the semantic map.
|
|
|
|
Clustering runs on the *full-dimensional* embeddings (not the 2-D projection),
|
|
so groupings reflect real semantic structure rather than a lossy layout. Output
|
|
feeds the ``__SEMANTIC_CLUSTERS__`` data token: per-node cluster id + top-5 cosine
|
|
neighbors (powering the hover panel without shipping raw vectors), plus a label
|
|
per cluster.
|
|
|
|
k-means is pure numpy (seeded k-means++ init, fixed iteration order) so results
|
|
are identical across runs — no scikit-learn, which lives only in the evals extra.
|
|
"""
|
|
|
|
import math
|
|
from collections import Counter
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
from cognee.modules.visualization.preprocessor import looks_like_identifier
|
|
from cognee.shared.logging_utils import get_logger
|
|
|
|
logger = get_logger("semantic_clusters")
|
|
|
|
CLUSTER_SEED = 42
|
|
TOP_NEIGHBORS = 5
|
|
_EPS = 1e-12
|
|
# Names longer than this read as chunk/summary text, not entity labels.
|
|
_MAX_LABEL_NAME = 40
|
|
|
|
|
|
def default_k(n: int) -> int:
|
|
"""k = min(12, max(2, round(sqrt(n/2)))), clamped to a sane range."""
|
|
if n < 2:
|
|
return 1
|
|
return min(12, max(2, round(math.sqrt(n / 2))))
|
|
|
|
|
|
def _kmeans_pp_init(x: np.ndarray, k: int, rng: np.random.Generator) -> np.ndarray:
|
|
"""Seeded k-means++ center selection."""
|
|
n = len(x)
|
|
first = int(rng.integers(n))
|
|
chosen = [first]
|
|
d2 = ((x - x[first]) ** 2).sum(axis=1)
|
|
for _ in range(1, k):
|
|
total = d2.sum()
|
|
probs = d2 / total if total > 0 else np.full(n, 1.0 / n)
|
|
idx = int(rng.choice(n, p=probs))
|
|
chosen.append(idx)
|
|
d2 = np.minimum(d2, ((x - x[idx]) ** 2).sum(axis=1))
|
|
return x[chosen].copy()
|
|
|
|
|
|
def kmeans(x: np.ndarray, k: int, seed: int = CLUSTER_SEED, max_iter: int = 50) -> np.ndarray:
|
|
"""Pure-numpy Lloyd's k-means. Deterministic given (x, k, seed).
|
|
|
|
Ties in assignment go to the lowest cluster index (argmin); empty clusters
|
|
keep their center rather than being re-seeded, so iteration order is fixed.
|
|
"""
|
|
n = len(x)
|
|
k = max(1, min(k, n))
|
|
rng = np.random.default_rng(seed)
|
|
centers = _kmeans_pp_init(x, k, rng)
|
|
labels = np.full(n, -1, dtype=int)
|
|
for _ in range(max_iter):
|
|
dists = ((x[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
|
|
new_labels = dists.argmin(axis=1)
|
|
if np.array_equal(new_labels, labels):
|
|
break
|
|
labels = new_labels
|
|
for c in range(k):
|
|
mask = labels == c
|
|
if mask.any():
|
|
centers[c] = x[mask].mean(axis=0)
|
|
return labels
|
|
|
|
|
|
def _nearest_neighbors(ids: List[str], x: np.ndarray, top: int) -> Dict[str, List[str]]:
|
|
"""Top-``top`` cosine neighbors per node (self excluded, stable tie order)."""
|
|
norms = np.linalg.norm(x, axis=1, keepdims=True)
|
|
unit = x / (norms + _EPS)
|
|
sim = unit @ unit.T
|
|
np.fill_diagonal(sim, -np.inf)
|
|
out: Dict[str, List[str]] = {}
|
|
for i, nid in enumerate(ids):
|
|
# Drop self before truncating: with <= ``top`` other nodes the self index
|
|
# would otherwise fall inside the slice (fill_diagonal only sorts it last).
|
|
order = [j for j in np.argsort(-sim[i], kind="stable") if j != i][:top]
|
|
out[nid] = [ids[j] for j in order]
|
|
return out
|
|
|
|
|
|
def _usable_name(nd: Dict[str, Any]) -> Optional[str]:
|
|
"""A node's name if it reads as a clean label — not a UUID/hash or a text blob."""
|
|
if nd.get("is_unnamed"):
|
|
# Preprocessor-flagged placeholder ("Unnamed Entity (ab12cd34)"): never a label.
|
|
return None
|
|
name = nd.get("name")
|
|
if not isinstance(name, str):
|
|
return None
|
|
name = name.strip()
|
|
if not name or len(name) > _MAX_LABEL_NAME or looks_like_identifier(name):
|
|
return None
|
|
return name
|
|
|
|
|
|
def default_label(member_nodes: List[Dict[str, Any]]) -> str:
|
|
"""Default cluster ``label_fn``: top-3 real ``Entity`` nodes (by degree, importance).
|
|
|
|
Entities win over DocumentChunk/TextSummary/EntityType so labels read as
|
|
concepts, not chunk text or type names. Identifier-shaped or over-long names
|
|
are skipped; a cluster with no usable name falls back to its dominant node
|
|
type (e.g. ``"TextSummary"``), then to ``"cluster"``.
|
|
"""
|
|
ranked = sorted(
|
|
member_nodes,
|
|
key=lambda nd: (nd.get("type") == "Entity", nd.get("degree", 0), nd.get("importance", 0.0)),
|
|
reverse=True,
|
|
)
|
|
names = [n for nd in ranked if (n := _usable_name(nd))]
|
|
if names:
|
|
return ", ".join(names[:3])
|
|
# No usable names (e.g. a cluster of chunks/summaries): name it by dominant type.
|
|
types = [str(nd.get("type")) for nd in member_nodes if nd.get("type")]
|
|
return Counter(types).most_common(1)[0][0] if types else "cluster"
|
|
|
|
|
|
def compute_clusters(
|
|
nodes: List[Dict[str, Any]],
|
|
embeddings: Dict[str, List[float]],
|
|
*,
|
|
k: Optional[int] = None,
|
|
seed: int = CLUSTER_SEED,
|
|
label_fn: Optional[Callable[[List[Dict[str, Any]]], str]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Cluster embedded nodes and precompute neighbors.
|
|
|
|
Returns ``{"clusters": [...], "node_cluster": {id: cluster_id},
|
|
"neighbors": {id: [neighbor_id, ...]}}``. Nodes without a vector are absent
|
|
from all three (the view leaves them uncolored).
|
|
|
|
``label_fn(member_nodes) -> str`` names each cluster; it is the sole labeling
|
|
seam. Defaults to :func:`default_label` (deterministic, offline). A one-line
|
|
LLM summarizer is just another ``label_fn`` — clustering never computes a
|
|
label the seam then discards, and no dependency on the #3601 harness.
|
|
"""
|
|
label_fn = label_fn or default_label
|
|
node_by_id = {str(n["id"]): n for n in nodes}
|
|
ids = sorted(nid for nid in node_by_id if nid in embeddings)
|
|
if not ids:
|
|
return {"clusters": [], "node_cluster": {}, "neighbors": {}}
|
|
|
|
x = np.array([embeddings[nid] for nid in ids], dtype=float)
|
|
k_effective = default_k(len(ids)) if k is None else k
|
|
k_effective = max(1, min(k_effective, len(ids)))
|
|
|
|
labels = kmeans(x, k_effective, seed)
|
|
neighbors = _nearest_neighbors(ids, x, TOP_NEIGHBORS)
|
|
|
|
clusters: List[Dict[str, Any]] = []
|
|
node_cluster: Dict[str, int] = {}
|
|
for c in range(k_effective):
|
|
members = [ids[i] for i in range(len(ids)) if labels[i] == c]
|
|
if not members:
|
|
continue
|
|
member_nodes = [node_by_id[m] for m in members]
|
|
label = label_fn(member_nodes)
|
|
for m in members:
|
|
node_cluster[m] = c
|
|
clusters.append({"id": c, "label": label, "node_ids": members, "size": len(members)})
|
|
|
|
return {"clusters": clusters, "node_cluster": node_cluster, "neighbors": neighbors}
|