From 64ca70015280ee52b06677d9f7db5949e62b64df Mon Sep 17 00:00:00 2001 From: g33kroid Date: Tue, 8 Sep 2026 04:40:08 -0400 Subject: [PATCH] perf(arcadedb): score vectors in the engine, not in Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three similarity searches fetched every candidate row *with its embedding* over Bolt, then computed cosine in Python with numpy, sorted, and truncated to the limit. For a 384-dimension embedder that ships ~1.5KB per candidate row across the wire to discard almost all of it, and it grows with the tenant's node count rather than with the limit. ArcadeDB supports the same `vector.similarity.cosine()` that the Neo4j path uses, so `get_vector_cosine_func_query()` already returns the correct expression for this provider through its fallthrough — nothing there needed changing. Scoring, `min_score` filtering, ordering and the limit all move into the query, which is what the Neo4j, FalkorDB and Kuzu drivers already do. The Python-side `_cosine_similarity()` helper and the numpy import go with it. Measured on ArcadeDB 26.9.1 (digest 02a1a74f), 1800 nodes, fastembed BGE-small (384-dim), graphiti's own load test: vector search p50 330.3ms -> 79.9ms vector search p95 354.9ms -> 133.5ms Results are unchanged: the same exact cosine over the same candidate set, computed one hop earlier. The functional suite is 15/15 on ArcadeDB with this applied. This also folds the embedding guard into the filter list rather than emitting it as a second WHERE, because the rewritten queries build one WHERE. That happens to fix the `WHERE ... WHERE ...` syntax error that made every filtered similarity search fail, which #2 fixes separately and for which #2 should get the credit. --- .../driver/arcadedb/operations/search_ops.py | 123 ++++++++---------- 1 file changed, 51 insertions(+), 72 deletions(-) diff --git a/graphiti_core/driver/arcadedb/operations/search_ops.py b/graphiti_core/driver/arcadedb/operations/search_ops.py index b8ee20967a..07edec26f6 100644 --- a/graphiti_core/driver/arcadedb/operations/search_ops.py +++ b/graphiti_core/driver/arcadedb/operations/search_ops.py @@ -17,8 +17,6 @@ import logging from typing import Any -import numpy as np - from graphiti_core.driver.driver import GraphProvider from graphiti_core.driver.operations.search_ops import SearchOperations from graphiti_core.driver.query_executor import QueryExecutor @@ -29,6 +27,7 @@ episodic_node_from_record, ) from graphiti_core.edges import EntityEdge +from graphiti_core.graph_queries import get_vector_cosine_func_query from graphiti_core.helpers import lucene_sanitize from graphiti_core.models.edges.edge_db_queries import get_entity_edge_return_query from graphiti_core.models.nodes.node_db_queries import ( @@ -48,18 +47,6 @@ MAX_QUERY_LENGTH = 128 -def _cosine_similarity(a: list[float], b: list[float]) -> float: - """Compute cosine similarity between two vectors.""" - a_arr = np.array(a, dtype=np.float64) - b_arr = np.array(b, dtype=np.float64) - dot = np.dot(a_arr, b_arr) - norm_a = np.linalg.norm(a_arr) - norm_b = np.linalg.norm(b_arr) - if norm_a == 0 or norm_b == 0: - return 0.0 - return float(dot / (norm_a * norm_b)) - - def _build_arcadedb_fulltext_query( query: str, group_ids: list[str] | None = None, @@ -171,43 +158,41 @@ async def node_similarity_search( filter_queries.append('n.group_id IN $group_ids') filter_params['group_ids'] = group_ids - filter_query = '' - if filter_queries: - filter_query = ' WHERE ' + (' AND '.join(filter_queries)) + # The embedding guard is a filter like any other. Emitting it as a second + # WHERE after the group/uuid filters produced `WHERE ... WHERE ...`, a + # syntax error whenever any filter was present. + filter_queries.append('n.name_embedding IS NOT NULL') + filter_query = ' WHERE ' + (' AND '.join(filter_queries)) - # Fetch candidate nodes with embeddings cypher = ( 'MATCH (n:Entity)' + filter_query + """ - WHERE n.name_embedding IS NOT NULL + WITH n, """ + + get_vector_cosine_func_query( + 'n.name_embedding', '$search_vector', GraphProvider.ARCADEDB + ) + + """ AS score + WHERE score > $min_score RETURN """ + get_entity_node_return_query(GraphProvider.ARCADEDB) - + """, - n.name_embedding AS name_embedding + + """ + ORDER BY score DESC + LIMIT $limit """ ) records, _, _ = await executor.execute_query( cypher, + search_vector=search_vector, + limit=limit, + min_score=min_score, routing_='r', **filter_params, ) - # Compute cosine similarity in Python and filter/sort - scored_records = [] - for r in records: - embedding = r.get('name_embedding') - if embedding is not None: - score = _cosine_similarity(embedding, search_vector) - if score > min_score: - scored_records.append((score, r)) - - scored_records.sort(key=lambda x: x[0], reverse=True) - scored_records = scored_records[:limit] - - return [entity_node_from_record(r) for _, r in scored_records] + return [entity_node_from_record(r) for r in records] async def node_bfs_search( self, @@ -350,43 +335,38 @@ async def edge_similarity_search( filter_params['target_uuid'] = target_node_uuid filter_queries.append('m.uuid = $target_uuid') - filter_query = '' - if filter_queries: - filter_query = ' WHERE ' + (' AND '.join(filter_queries)) + filter_queries.append('e.fact_embedding IS NOT NULL') + filter_query = ' WHERE ' + (' AND '.join(filter_queries)) - # Fetch candidate edges with embeddings cypher = ( 'MATCH (n:Entity)-[e:RELATES_TO]->(m:Entity)' + filter_query + """ - WHERE e.fact_embedding IS NOT NULL - RETURN DISTINCT + WITH DISTINCT e, n, m, """ + + get_vector_cosine_func_query( + 'e.fact_embedding', '$search_vector', GraphProvider.ARCADEDB + ) + + """ AS score + WHERE score > $min_score + RETURN """ + get_entity_edge_return_query(GraphProvider.ARCADEDB) - + """, - e.fact_embedding AS fact_embedding + + """ + ORDER BY score DESC + LIMIT $limit """ ) records, _, _ = await executor.execute_query( cypher, + search_vector=search_vector, + limit=limit, + min_score=min_score, routing_='r', **filter_params, ) - # Compute cosine similarity in Python and filter/sort - scored_records = [] - for r in records: - embedding = r.get('fact_embedding') - if embedding is not None: - score = _cosine_similarity(embedding, search_vector) - if score > min_score: - scored_records.append((score, r)) - - scored_records.sort(key=lambda x: x[0], reverse=True) - scored_records = scored_records[:limit] - - return [entity_edge_from_record(r) for _, r in scored_records] + return [entity_edge_from_record(r) for r in records] async def edge_bfs_search( self, @@ -553,41 +533,40 @@ async def community_similarity_search( ) -> list[CommunityNode]: query_params: dict[str, Any] = {} - group_filter_query = '' + group_filter_query = ' WHERE c.name_embedding IS NOT NULL' if group_ids is not None: - group_filter_query += ' WHERE c.group_id IN $group_ids' + group_filter_query += ' AND c.group_id IN $group_ids' query_params['group_ids'] = group_ids - # Fetch candidate communities with embeddings cypher = ( 'MATCH (c:Community)' + group_filter_query + """ - WHERE c.name_embedding IS NOT NULL + WITH c, """ + + get_vector_cosine_func_query( + 'c.name_embedding', '$search_vector', GraphProvider.ARCADEDB + ) + + """ AS score + WHERE score > $min_score RETURN """ + COMMUNITY_NODE_RETURN + + """ + ORDER BY score DESC + LIMIT $limit + """ ) records, _, _ = await executor.execute_query( cypher, + search_vector=search_vector, + limit=limit, + min_score=min_score, routing_='r', **query_params, ) - # Compute cosine similarity in Python and filter/sort - scored_records = [] - for r in records: - embedding = r.get('name_embedding') - if embedding is not None: - score = _cosine_similarity(embedding, search_vector) - if score > min_score: - scored_records.append((score, r)) - - scored_records.sort(key=lambda x: x[0], reverse=True) - scored_records = scored_records[:limit] - - return [community_node_from_record(r) for _, r in scored_records] + return [community_node_from_record(r) for r in records] # --- Rerankers ---