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
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Unit tests for the embedding join — the seam between graph nodes and vectors.
|
|
|
|
Fully offline and deterministic: a fake vector engine stands in for the real
|
|
adapter (the single mocking seam), so there are no LLM calls and no live store.
|
|
Covers collection resolution, batched retrieve, the re-embed fallback for adapters
|
|
without ``include_vector`` support, and the deterministic over-cap sample.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
from cognee.infrastructure.databases.vector.models.ScoredResult import ScoredResult
|
|
from cognee.modules.visualization.embedding_join import (
|
|
DEFAULT_INDEX_FIELDS,
|
|
fetch_node_embeddings,
|
|
select_nodes,
|
|
)
|
|
|
|
|
|
class FakeEmbeddingEngine:
|
|
"""Deterministic embed_text: one call, one vector per text, length-based."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def embed_text(self, texts):
|
|
self.calls.append(list(texts))
|
|
return [[float(len(t)), 1.0, 2.0] for t in texts]
|
|
|
|
|
|
class FakeVectorEngine:
|
|
"""A supporting adapter: ``retrieve()`` declares ``include_vector`` like LanceDB.
|
|
|
|
``store`` maps collection -> {id: vector}. ``attaches_vector=False`` models an
|
|
adapter that accepts the flag but returns rows carrying no vector, exercising
|
|
the "accepted the flag but returned nothing" re-embed branch.
|
|
"""
|
|
|
|
def __init__(self, store, attaches_vector=True):
|
|
self.store = store
|
|
self.attaches_vector = attaches_vector
|
|
self.embedding_engine = FakeEmbeddingEngine()
|
|
self.retrieve_calls = []
|
|
|
|
async def has_collection(self, collection_name):
|
|
return collection_name in self.store
|
|
|
|
async def retrieve(self, collection_name, data_point_ids, *, include_vector=False):
|
|
self.retrieve_calls.append((collection_name, list(data_point_ids)))
|
|
rows = self.store.get(collection_name, {})
|
|
results = []
|
|
for did in data_point_ids:
|
|
if did in rows:
|
|
payload = {"text": f"payload-{did}"}
|
|
if include_vector and self.attaches_vector:
|
|
payload = {**payload, "vector": rows[did]}
|
|
results.append(ScoredResult(id=did, payload=payload, score=0))
|
|
return results
|
|
|
|
|
|
class LegacyVectorEngine(FakeVectorEngine):
|
|
"""An unsupported adapter: ``retrieve()`` has no ``include_vector`` param (like
|
|
PGVector). The signature probe must route it straight to the re-embed fallback,
|
|
so this ``retrieve()`` is never even called with the flag."""
|
|
|
|
async def retrieve(self, collection_name, data_point_ids):
|
|
self.retrieve_calls.append((collection_name, list(data_point_ids)))
|
|
rows = self.store.get(collection_name, {})
|
|
return [
|
|
ScoredResult(id=did, payload={"text": f"payload-{did}"}, score=0)
|
|
for did in data_point_ids
|
|
if did in rows
|
|
]
|
|
|
|
|
|
# ScoredResult.id is a UUID, so fixtures use canonical UUID strings.
|
|
E1 = "11111111-1111-1111-1111-111111111111"
|
|
E2 = "22222222-2222-2222-2222-222222222222"
|
|
T1 = "33333333-3333-3333-3333-333333333333"
|
|
G1 = "44444444-4444-4444-4444-444444444444"
|
|
S1 = "55555555-5555-5555-5555-555555555555"
|
|
C1 = "66666666-6666-6666-6666-666666666666"
|
|
|
|
|
|
def _node(nid, ntype, **extra):
|
|
return {"id": nid, "type": ntype, "name": f"name-{nid}", **extra}
|
|
|
|
|
|
def test_collection_resolution_one_batched_retrieve_per_type():
|
|
# Two Entity nodes + one EntityType node -> one retrieve on Entity_name and
|
|
# one on EntityType_name, each batched with the correct ids.
|
|
store = {
|
|
"Entity_name": {E1: [0.1, 0.2, 0.3], E2: [0.4, 0.5, 0.6]},
|
|
"EntityType_name": {T1: [0.7, 0.8, 0.9]},
|
|
}
|
|
engine = FakeVectorEngine(store)
|
|
nodes = [_node(E1, "Entity"), _node(E2, "Entity"), _node(T1, "EntityType")]
|
|
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert result == {
|
|
E1: [0.1, 0.2, 0.3],
|
|
E2: [0.4, 0.5, 0.6],
|
|
T1: [0.7, 0.8, 0.9],
|
|
}
|
|
# Exactly one batched retrieve per collection, never per node.
|
|
called = {c[0]: c[1] for c in engine.retrieve_calls}
|
|
assert set(called) == {"Entity_name", "EntityType_name"}
|
|
assert len(engine.retrieve_calls) == 2
|
|
assert set(called["Entity_name"]) == {E1, E2}
|
|
|
|
|
|
def test_unknown_type_and_missing_collection_skipped():
|
|
store = {"Entity_name": {E1: [1.0, 0.0, 0.0]}}
|
|
engine = FakeVectorEngine(store)
|
|
nodes = [
|
|
_node(E1, "Entity"),
|
|
_node(G1, "GraphNodeType"), # not in DEFAULT_INDEX_FIELDS -> skipped
|
|
_node(S1, "TextSummary"), # known type but no collection -> skipped
|
|
]
|
|
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert result == {E1: [1.0, 0.0, 0.0]}
|
|
assert [c[0] for c in engine.retrieve_calls] == ["Entity_name"]
|
|
|
|
|
|
def test_missing_ids_absent_not_reembedded():
|
|
# Collection exists and returns vectors for present ids; ids absent from the
|
|
# store are simply missing (layout handles them), NOT re-embedded.
|
|
store = {"Entity_name": {E1: [0.1, 0.1, 0.1]}}
|
|
engine = FakeVectorEngine(store)
|
|
nodes = [_node(E1, "Entity"), _node(E2, "Entity")]
|
|
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert result == {E1: [0.1, 0.1, 0.1]}
|
|
assert engine.embedding_engine.calls == [] # no re-embed
|
|
|
|
|
|
def test_reembed_fallback_when_include_vector_unsupported():
|
|
# Adapter whose retrieve() lacks include_vector: the signature probe routes it
|
|
# to the re-embed fallback, positioning every node via one batched embed_text.
|
|
store = {"Entity_name": {E1: [9.9], E2: [9.9]}}
|
|
engine = LegacyVectorEngine(store)
|
|
nodes = [_node(E1, "Entity", name="abc"), _node(E2, "Entity", name="abcd")]
|
|
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
# Fallback embeds the indexed field (name) once, in a single batch, and the
|
|
# unsupported retrieve() is never called with the flag.
|
|
assert engine.retrieve_calls == []
|
|
assert len(engine.embedding_engine.calls) == 1
|
|
assert engine.embedding_engine.calls[0] == ["abc", "abcd"]
|
|
assert result == {E1: [3.0, 1.0, 2.0], E2: [4.0, 1.0, 2.0]}
|
|
|
|
|
|
def test_reembed_uses_text_field_for_text_types():
|
|
store = {"DocumentChunk_text": {C1: [0.0]}}
|
|
engine = LegacyVectorEngine(store)
|
|
nodes = [{"id": C1, "type": "DocumentChunk", "name": "ignored", "text": "hello"}]
|
|
|
|
asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert engine.embedding_engine.calls == [["hello"]]
|
|
|
|
|
|
def test_reembed_fallback_when_adapter_ignores_include_vector():
|
|
# Adapter that accepts include_vector (no TypeError) but never attaches a
|
|
# vector -> retrieve returns rows, none carrying a vector, so the join must
|
|
# fall back to a single batched re-embed (the "not found and results" branch).
|
|
store = {"Entity_name": {E1: [9.9], E2: [9.9]}}
|
|
engine = FakeVectorEngine(store, attaches_vector=False)
|
|
nodes = [_node(E1, "Entity", name="abc"), _node(E2, "Entity", name="abcd")]
|
|
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert engine.retrieve_calls # retrieve WAS attempted before the fallback
|
|
assert len(engine.embedding_engine.calls) == 1
|
|
assert engine.embedding_engine.calls[0] == ["abc", "abcd"]
|
|
assert result == {E1: [3.0, 1.0, 2.0], E2: [4.0, 1.0, 2.0]}
|
|
|
|
|
|
def test_sampling_cap_deterministic():
|
|
nodes = [_node(f"{i:05d}", "Entity") for i in range(3000)]
|
|
|
|
# Cap at 2000 and assert two runs agree (deterministic seeded sample).
|
|
picked1 = select_nodes(nodes, 2000)
|
|
picked2 = select_nodes(nodes, 2000)
|
|
assert len(picked1) == 2000
|
|
assert [n["id"] for n in picked1] == [n["id"] for n in picked2]
|
|
# id-sorted output
|
|
assert [n["id"] for n in picked1] == sorted(n["id"] for n in picked1)
|
|
|
|
|
|
def test_default_engine_path_awaits_async_factory(monkeypatch):
|
|
# With no engine injected, fetch_node_embeddings must resolve via the
|
|
# canonical ``await get_vector_engine_async()`` (not the deprecated sync
|
|
# ``get_vector_engine()``). A working async factory here proves the default
|
|
# production path resolves real embeddings.
|
|
import cognee.infrastructure.databases.vector as vector_package
|
|
|
|
engine = FakeVectorEngine({"Entity_name": {E1: [1.0, 0.0, 0.0]}})
|
|
|
|
async def fake_get_vector_engine_async():
|
|
return engine
|
|
|
|
monkeypatch.setattr(vector_package, "get_vector_engine_async", fake_get_vector_engine_async)
|
|
|
|
result = asyncio.run(fetch_node_embeddings([_node(E1, "Entity")]))
|
|
|
|
assert result == {E1: [1.0, 0.0, 0.0]}
|
|
|
|
|
|
def test_default_index_fields_cover_core_types():
|
|
for t in ("Entity", "EntityType", "TextSummary", "DocumentChunk", "TextDocument"):
|
|
assert t in DEFAULT_INDEX_FIELDS
|
|
|
|
|
|
class _RecordingLogger:
|
|
"""Captures formatted log messages regardless of the logging backend."""
|
|
|
|
def __init__(self):
|
|
self.records = []
|
|
|
|
def _rec(self, level, msg, args):
|
|
self.records.append((level, msg % args if args else msg))
|
|
|
|
def info(self, msg, *args):
|
|
self._rec("info", msg, args)
|
|
|
|
def warning(self, msg, *args):
|
|
self._rec("warning", msg, args)
|
|
|
|
def debug(self, *args, **kwargs):
|
|
pass
|
|
|
|
def messages(self, level):
|
|
return [m for lvl, m in self.records if lvl == level]
|
|
|
|
|
|
def test_join_logs_hit_rate(monkeypatch):
|
|
# One of two Entity nodes resolves -> a hit-rate INFO line reports 1/2.
|
|
from cognee.modules.visualization import embedding_join
|
|
|
|
rec = _RecordingLogger()
|
|
monkeypatch.setattr(embedding_join, "logger", rec)
|
|
|
|
engine = FakeVectorEngine({"Entity_name": {E1: [1.0, 0.0, 0.0]}})
|
|
nodes = [_node(E1, "Entity"), _node(E2, "Entity")]
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert result == {E1: [1.0, 0.0, 0.0]}
|
|
assert any(
|
|
"resolved 1/2 node embeddings across 1 collection(s)" in m for m in rec.messages("info")
|
|
)
|
|
assert rec.messages("warning") == [] # partial success is not a warning
|
|
|
|
|
|
def test_join_warns_with_diagnostics_when_nothing_resolves(monkeypatch):
|
|
# Zero resolution over non-empty input -> a WARNING naming the missing
|
|
# collection and the unmapped type, so a blank map is diagnosable.
|
|
from cognee.modules.visualization import embedding_join
|
|
|
|
rec = _RecordingLogger()
|
|
monkeypatch.setattr(embedding_join, "logger", rec)
|
|
|
|
engine = FakeVectorEngine({}) # no collections at all
|
|
nodes = [
|
|
_node(S1, "TextSummary"), # known type, collection TextSummary_text missing
|
|
_node(G1, "GraphNodeType"), # unmapped type
|
|
]
|
|
result = asyncio.run(fetch_node_embeddings(nodes, vector_engine=engine))
|
|
|
|
assert result == {}
|
|
warnings = rec.messages("warning")
|
|
assert any("no embeddings resolved" in m for m in warnings)
|
|
joined = " ".join(warnings)
|
|
assert "TextSummary_text" in joined # missing collection surfaced
|
|
assert "GraphNodeType" in joined # unmapped type surfaced
|