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
195 lines
7.7 KiB
Python
195 lines
7.7 KiB
Python
"""Join graph nodes to their stored embeddings — the plumbing behind the semantic map.
|
|
|
|
``visualize_graph()`` forwards only nodes/links; no embeddings ride along. This
|
|
module fetches the embedding for each node by looking it up in the vector store,
|
|
reusing the fact that a graph node id is stored verbatim as the vector-row id
|
|
(both sides use ``str(data_point.id)``).
|
|
|
|
Strategy per node type:
|
|
* Group node ids by type and derive the collection ``f"{Type}_{field}"`` from
|
|
the type's indexed field.
|
|
* One batched ``retrieve(..., include_vector=True)`` per collection — never per
|
|
node. On adapters that don't support ``include_vector`` (everything except
|
|
LanceDB), fall back to re-embedding the indexed field in one batch (the stored
|
|
vector is ``embed(indexed_field)``, so the re-embedded vector matches).
|
|
|
|
Bounding lives in :func:`select_nodes`: the orchestrator samples the graph down
|
|
to ``SEMANTIC_NODE_CAP`` once, and the fetch, projection, clustering, and
|
|
de-overlap passes all operate on that same subset.
|
|
|
|
Any vector-engine failure is logged and yields a partial/empty dict — the classic
|
|
render must never break because the semantic tab couldn't fetch vectors.
|
|
"""
|
|
|
|
import inspect
|
|
import random
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from cognee.shared.logging_utils import get_logger
|
|
|
|
logger = get_logger("embedding_join")
|
|
|
|
# type_name -> indexed field. The vector collection is ``f"{type_name}_{field}"``.
|
|
# Mirrors each DataPoint subclass's ``metadata["index_fields"]``; this fallback
|
|
# covers the node types the graph visualization actually surfaces.
|
|
DEFAULT_INDEX_FIELDS: Dict[str, str] = {
|
|
"Entity": "name",
|
|
"EntityType": "name",
|
|
"TextSummary": "text",
|
|
"DocumentChunk": "text",
|
|
"TextDocument": "name",
|
|
}
|
|
|
|
# Max nodes the semantic map renders. Bounds the vector fetch AND the O(n²)
|
|
# layout/neighbor passes downstream — everything runs on the same sample.
|
|
SEMANTIC_NODE_CAP = 2000
|
|
|
|
# Seed for the deterministic over-cap sample. Fixed so a given graph always
|
|
# samples the same nodes across runs (snapshot tests depend on this).
|
|
SAMPLE_SEED = 42
|
|
|
|
|
|
def select_nodes(nodes: List[Dict[str, Any]], cap: int = SEMANTIC_NODE_CAP) -> List[Dict[str, Any]]:
|
|
"""Sort nodes by id and, if over ``cap``, take a deterministic seeded sample.
|
|
|
|
The single bounding step for the semantic map: the embedding fetch and every
|
|
downstream pass (projection, clustering, de-overlap) run on this subset only.
|
|
"""
|
|
ordered = sorted(nodes, key=lambda n: str(n["id"]))
|
|
if len(ordered) <= cap:
|
|
return ordered
|
|
rng = random.Random(SAMPLE_SEED)
|
|
picked = sorted(rng.sample(range(len(ordered)), cap))
|
|
logger.warning(
|
|
"select_nodes: %d nodes exceeds cap %d; the semantic map shows a deterministic sample",
|
|
len(ordered),
|
|
cap,
|
|
)
|
|
return [ordered[i] for i in picked]
|
|
|
|
|
|
async def _reembed(
|
|
vector_engine, type_nodes: List[Dict[str, Any]], field: str
|
|
) -> Dict[str, List[float]]:
|
|
"""Fallback: re-embed each node's indexed field in one batch."""
|
|
texts: List[str] = []
|
|
ids: List[str] = []
|
|
for node in type_nodes:
|
|
value = node.get(field)
|
|
if value is None:
|
|
value = node.get("name")
|
|
if value is None:
|
|
continue
|
|
texts.append(str(value))
|
|
ids.append(str(node["id"]))
|
|
if not texts:
|
|
return {}
|
|
vectors = await vector_engine.embedding_engine.embed_text(texts)
|
|
return {nid: list(vec) for nid, vec in zip(ids, vectors)}
|
|
|
|
|
|
async def _fetch_for_collection(
|
|
vector_engine, collection: str, type_nodes: List[Dict[str, Any]], field: str
|
|
) -> Dict[str, List[float]]:
|
|
"""One batched retrieve for a collection, with re-embed fallback."""
|
|
ids = [str(node["id"]) for node in type_nodes]
|
|
# Capability detection (not error handling): only LanceDB's retrieve() declares
|
|
# ``include_vector``. Probe the signature — the same idiom run_async / Task use
|
|
# for optional params — so a genuine TypeError raised *inside* a supporting
|
|
# retrieve() surfaces via fetch_node_embeddings' ``except Exception`` instead of
|
|
# being silently reinterpreted as "unsupported" and masked behind a re-embed.
|
|
try:
|
|
supports_vector = "include_vector" in inspect.signature(vector_engine.retrieve).parameters
|
|
except (ValueError, TypeError):
|
|
supports_vector = False
|
|
if not supports_vector:
|
|
return await _reembed(vector_engine, type_nodes, field)
|
|
|
|
results = await vector_engine.retrieve(collection, ids, include_vector=True)
|
|
|
|
found: Dict[str, List[float]] = {}
|
|
for result in results:
|
|
payload = result.payload if isinstance(result.payload, dict) else None
|
|
vector = payload.get("vector") if payload else None
|
|
if vector is not None:
|
|
found[str(result.id)] = list(vector)
|
|
|
|
if not found and results:
|
|
# Adapter accepted the flag but returned no vectors -> re-embed.
|
|
return await _reembed(vector_engine, type_nodes, field)
|
|
return found
|
|
|
|
|
|
async def fetch_node_embeddings(
|
|
nodes: List[Dict[str, Any]],
|
|
vector_engine=None,
|
|
index_fields: Optional[Dict[str, str]] = None,
|
|
) -> Dict[str, List[float]]:
|
|
"""Return ``{node_id: vector}`` for as many nodes as the vector store can supply.
|
|
|
|
Args:
|
|
nodes: renderer-facing node dicts (each carries ``id`` and ``type``),
|
|
already bounded by :func:`select_nodes`.
|
|
vector_engine: injected engine (defaults to ``await get_vector_engine_async()``).
|
|
This is the single mocking seam for tests.
|
|
index_fields: type -> indexed-field override (defaults to
|
|
``DEFAULT_INDEX_FIELDS``).
|
|
|
|
Missing vectors are simply absent from the dict; the layout handles them.
|
|
"""
|
|
fields = index_fields or DEFAULT_INDEX_FIELDS
|
|
|
|
if vector_engine is None:
|
|
from cognee.infrastructure.databases.vector import get_vector_engine_async
|
|
|
|
vector_engine = await get_vector_engine_async()
|
|
|
|
by_type: Dict[Optional[str], List[Dict[str, Any]]] = defaultdict(list)
|
|
for node in nodes:
|
|
by_type[node.get("type")].append(node)
|
|
|
|
embeddings: Dict[str, List[float]] = {}
|
|
hit_collections = 0
|
|
missing_collections: List[str] = []
|
|
unmapped_types: List[str] = []
|
|
for type_name, type_nodes in by_type.items():
|
|
field = fields.get(type_name)
|
|
if not field:
|
|
if type_name is not None:
|
|
unmapped_types.append(type_name)
|
|
continue
|
|
collection = f"{type_name}_{field}"
|
|
try:
|
|
if not await vector_engine.has_collection(collection):
|
|
missing_collections.append(collection)
|
|
continue
|
|
found = await _fetch_for_collection(vector_engine, collection, type_nodes, field)
|
|
except Exception as exc: # never let a vector-store failure break the render
|
|
logger.warning("fetch_node_embeddings: fetch failed for %s: %s", collection, exc)
|
|
continue
|
|
if found:
|
|
hit_collections += 1
|
|
embeddings.update(found)
|
|
|
|
# Join hit-rate: turn a silent empty map into a diagnosable one. A zero
|
|
# resolution over non-empty input almost always means an id/collection-name
|
|
# mismatch — surface which collections were missing and which types were
|
|
# unmapped instead of rendering blank with no signal.
|
|
total = len(nodes)
|
|
logger.info(
|
|
"fetch_node_embeddings: resolved %d/%d node embeddings across %d collection(s)",
|
|
len(embeddings),
|
|
total,
|
|
hit_collections,
|
|
)
|
|
if total and not embeddings:
|
|
logger.warning(
|
|
"fetch_node_embeddings: no embeddings resolved — the semantic map will be empty. "
|
|
"Missing collections: %s. Unmapped node types: %s.",
|
|
missing_collections or "none",
|
|
unmapped_types or "none",
|
|
)
|
|
|
|
return embeddings
|