From cd0532f50c4bc9767ca55f586595041311ed7b27 Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Wed, 20 May 2026 21:19:11 -1000 Subject: [PATCH 1/3] =?UTF-8?q?fix(falkordb):=20avoid=20O(matches=C3=97gra?= =?UTF-8?q?ph)=20re-MATCH=20in=20edge=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FalkorDB driver's edge_fulltext_search and edge_bfs_search both re-MATCH each relationship returned by an index/path call: CALL db.idx.fulltext.queryRelationships(...) YIELD relationship AS rel MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]->(m:Entity) FalkorDB's planner does not use the RELATES_TO uuid index to anchor a relationship inside a node-rel-node pattern, so each yielded row triggers a graph-wide scan. On a moderate graph (~19k RELATES_TO edges) a single search reached 25 minutes and pinned multiple cores. The fulltext/path call already returns the relationship object, so the endpoints are available directly via startNode()/endNode() — no re-MATCH needed. The same fix applies to both functions. Measured on a live instance (2,852 entities / 18,849 RELATES_TO edges): - edge_fulltext_search: ~25 min -> ~12 ms - edge_bfs_search (d=2): >45 s -> ~1 s edge_bfs_search additionally gains correctness: the previous undirected re-MATCH `(n)-[e {uuid}]-(m)` returned each edge twice with swapped source/target; startNode()/endNode() yields the true orientation once. Co-Authored-By: Claude Opus 4.7 --- graphiti_core/driver/falkordb/operations/search_ops.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphiti_core/driver/falkordb/operations/search_ops.py b/graphiti_core/driver/falkordb/operations/search_ops.py index c8935aa85b..f7762ba2f2 100644 --- a/graphiti_core/driver/falkordb/operations/search_ops.py +++ b/graphiti_core/driver/falkordb/operations/search_ops.py @@ -314,8 +314,8 @@ async def edge_fulltext_search( 'edge_name_and_fact', limit=limit, provider=GraphProvider.FALKORDB ) + """ - YIELD relationship AS rel, score - MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]->(m:Entity) + YIELD relationship AS e, score + WITH e, score, startNode(e) AS n, endNode(e) AS m """ + filter_query + """ @@ -427,7 +427,9 @@ async def edge_bfs_search( UNWIND $bfs_origin_node_uuids AS origin_uuid MATCH path = (origin {{uuid: origin_uuid}})-[:RELATES_TO|MENTIONS*1..{max_depth}]->(:Entity) UNWIND relationships(path) AS rel - MATCH (n:Entity)-[e:RELATES_TO {{uuid: rel.uuid}}]-(m:Entity) + WITH rel AS e, startNode(rel) AS n, endNode(rel) AS m + WHERE type(e) = 'RELATES_TO' + WITH e, n, m """ + filter_query + """ From 24c0b92c88fb1925ce7a8cbb932cc6bc272ff455 Mon Sep 17 00:00:00 2001 From: Jared Loman Date: Wed, 20 May 2026 22:06:13 -1000 Subject: [PATCH 2/3] =?UTF-8?q?fix(search):=20avoid=20O(matches=C3=97graph?= =?UTF-8?q?)=20re-MATCH=20in=20edge=20fulltext=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `edge_fulltext_search` in search_utils.py — the live search path used by `Graphiti.search()` — re-MATCHes every relationship the fulltext index call already yielded: CALL db.idx.fulltext.queryRelationships(...) YIELD relationship AS rel MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]->(m:Entity) The planner cannot use the RELATES_TO uuid index to anchor a relationship inside a node-rel-node pattern, so each yielded row triggers a graph-wide scan. On a ~19k-edge FalkorDB graph a single search reached ~25 minutes. The fulltext call already returns the relationship, so the endpoints are available via startNode()/endNode() — no re-MATCH. ~25 min -> <1 s, verified end-to-end against a live FalkorDB instance. This is the same fix as the earlier commit applied to the FalkorDB driver's search_ops.py; this commit covers the generic search_utils.py path, which is the one actually executed when `driver.search_interface` is unset (the default). Co-Authored-By: Claude Opus 4.7 --- graphiti_core/search/search_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphiti_core/search/search_utils.py b/graphiti_core/search/search_utils.py index 2c9d63882e..fbc499804d 100644 --- a/graphiti_core/search/search_utils.py +++ b/graphiti_core/search/search_utils.py @@ -201,8 +201,8 @@ async def edge_fulltext_search( return [] match_query = """ - YIELD relationship AS rel, score - MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]->(m:Entity) + YIELD relationship AS e, score + WITH e, score, startNode(e) AS n, endNode(e) AS m """ if driver.provider == GraphProvider.KUZU: match_query = """ From 65653e8d941d00b8e05fd2a5e863b01811212a36 Mon Sep 17 00:00:00 2001 From: Daniel Chalef <131175+danielchalef@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:35:27 -0700 Subject: [PATCH 3/3] test: guard FalkorDB edge-search query shape (no re-MATCH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB-free query-shape regression test asserting edge_fulltext_search and edge_bfs_search derive endpoints via startNode/endNode instead of the O(matches×graph) re-MATCH by uuid. Placed at tests/ root (not tests/driver/, which the unit-test workflow ignores) so it runs in CI; falkordb is installed there via `uv sync --all-extras`. Harvested from #1494 (credit @mturac) and adapted to this PR's BFS rewrite (type(e) guard after rebinding rel -> e). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_falkordb_search_ops.py | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_falkordb_search_ops.py diff --git a/tests/test_falkordb_search_ops.py b/tests/test_falkordb_search_ops.py new file mode 100644 index 0000000000..de8d1edb5e --- /dev/null +++ b/tests/test_falkordb_search_ops.py @@ -0,0 +1,68 @@ +"""Query-shape regression tests for FalkorDB edge search (no live database). + +Guards the fix for the O(matches×graph) re-MATCH in edge search: the edge +fulltext and BFS queries must derive endpoints from the relationship returned by +the index (``startNode``/``endNode``) instead of re-matching every yielded edge +by uuid against the whole graph (``MATCH (n)-[e {uuid: rel.uuid}]->(m)``), which +caused multi-minute query times on large graphs. + +Harvested and adapted from #1494's driver test; runs as a unit test (a recording +executor captures the emitted Cypher, so no FalkorDB connection is required). +""" + +from typing import Any + +import pytest + +from graphiti_core.driver.falkordb.operations.search_ops import FalkorSearchOperations +from graphiti_core.search.search_filters import SearchFilters + + +class RecordingExecutor: + """Captures the Cypher and params a search method emits, returning no rows.""" + + def __init__(self): + self.cypher_query = '' + self.params: dict[str, Any] = {} + + async def execute_query(self, cypher_query_: str, **kwargs: Any): + self.cypher_query = cypher_query_ + self.params = kwargs + return [], None, None + + +@pytest.mark.asyncio +async def test_edge_fulltext_search_uses_returned_relationship_endpoints(): + executor = RecordingExecutor() + operations = FalkorSearchOperations() + + await operations.edge_fulltext_search( + executor, + 'api test system', + SearchFilters(), + group_ids=['group-a'], + ) + + assert 'YIELD relationship AS e, score' in executor.cypher_query + assert 'WITH e, score, startNode(e) AS n, endNode(e) AS m' in executor.cypher_query + # The expensive per-row re-MATCH must be gone. + assert 'uuid: rel.uuid' not in executor.cypher_query + + +@pytest.mark.asyncio +async def test_edge_bfs_search_uses_path_relationship_endpoints(): + executor = RecordingExecutor() + operations = FalkorSearchOperations() + + await operations.edge_bfs_search( + executor, + ['origin-uuid'], + 2, + SearchFilters(), + group_ids=['group-a'], + ) + + assert 'WITH rel AS e, startNode(rel) AS n, endNode(rel) AS m' in executor.cypher_query + # rel is rebound to e before the type guard, so the filter is on type(e). + assert "WHERE type(e) = 'RELATES_TO'" in executor.cypher_query + assert 'uuid: rel.uuid' not in executor.cypher_query