Skip to content

fix: avoid O(matches×graph) re-MATCH in edge search#1500

Open
jaredloman wants to merge 3 commits into
getzep:mainfrom
jaredloman:fix/falkordb-edge-search-rematch-perf
Open

fix: avoid O(matches×graph) re-MATCH in edge search#1500
jaredloman wants to merge 3 commits into
getzep:mainfrom
jaredloman:fix/falkordb-edge-search-rematch-perf

Conversation

@jaredloman

@jaredloman jaredloman commented May 21, 2026

Copy link
Copy Markdown

Problem

Edge search re-MATCHes every relationship that a fulltext / path call already yielded, instead of using the relationship object directly:

CALL db.idx.fulltext.queryRelationships('edge_name_and_fact', $query) YIELD relationship AS rel, score
MATCH (n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]->(m:Entity)

The planner can't use the RELATES_TO.uuid index to anchor a relationship inside a (node)-[rel {prop}]->(node) pattern, so each yielded row triggers a graph-wide scan — O(results × graph).

On a moderately sized FalkorDB graph (2,852 Entity / 18,849 RELATES_TO edges) a single search_memory_facts reached ~25 minutes (GRAPH.SLOWLOG latency 1,541,600 ms) and pinned multiple cores. The db.idx.fulltext.queryRelationships call itself returns in <1 ms; the re-MATCH is the entire cost.

Affected code

This re-MATCH pattern appears in two places:

  1. graphiti_core/search/search_utils.pyedge_fulltext_search, the live path used by Graphiti.search() when driver.search_interface is unset (the default).
  2. graphiti_core/driver/falkordb/operations/search_ops.pyedge_fulltext_search + edge_bfs_search in the per-driver search operations.

Fix

The fulltext / path call already returns the relationship object, so the endpoints are available directly via startNode() / endNode() — the re-MATCH is unnecessary:

CALL db.idx.fulltext.queryRelationships('edge_name_and_fact', $query) YIELD relationship AS e, score
WITH e, score, startNode(e) AS n, endNode(e) AS m

edge_bfs_search gets the same treatment, with a type(e) = 'RELATES_TO' guard since relationships(path) over a RELATES_TO|MENTIONS path also yields MENTIONS edges that the old [e:RELATES_TO {uuid: ...}] pattern filtered out implicitly.

Measurements

Verified end-to-end against a live FalkorDB instance (graphiti-core 0.28.x) — identical search_memory_facts calls:

Search Before After
edge_fulltext_search (search_utils.py path) ~25 min (timeout) <1 s
edge_fulltext_search (driver search_ops.py) ~25 min ~12 ms
edge_bfs_search (depth 2, 289-degree origin) >45 s ~1 s

Correctness note (edge_bfs_search)

The old re-MATCH there was undirected(n:Entity)-[e:RELATES_TO {uuid: rel.uuid}]-(m:Entity) — which binds a pinned edge in both directions, returning each edge twice with source/target swapped (RETURN DISTINCT doesn't collapse them). startNode(e) / endNode(e) returns each edge once in its true orientation.

🤖 Generated with Claude Code

@jaredloman jaredloman changed the title fix(falkordb): avoid O(matches×graph) re-MATCH in edge search fix: avoid O(matches×graph) re-MATCH in edge search May 21, 2026
jaredloman and others added 3 commits June 9, 2026 07:32
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 getzep#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) <noreply@anthropic.com>
@danielchalef
danielchalef force-pushed the fix/falkordb-edge-search-rematch-perf branch from 927464a to 65653e8 Compare June 9, 2026 14:35
@zep-cla-assistant

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. For privacy information, see our Privacy Notice. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA behalf on myself, e-mail: example@example.com

or

I have read the CLA Document and I hereby sign the CLA behalf of my company, e-mail: example@example.com

Signature is valid for 6 months.


This bot will be retriggered when the Contributor License Agreement comment has been provided. Posted by the CLA Assistant Lite bot.

@danielchalef

Copy link
Copy Markdown
Member

Maintainer note: rebased onto current main and added a DB-free query-shape regression test (tests/test_falkordb_search_ops.py, harvested from #1494 — credit @mturac — and adapted to this PR's BFS type(e) guard). Selecting this as the canonical fix for the O(matches×graph) edge re-MATCH: it's the root-cause startNode(e)/endNode(e) rewrite, and crucially it patches both the FalkorDB driver search_ops.py and graphiti_core/search/search_utils.py — the latter is the path that actually executes today (FalkorDriver doesn't set search_interface). It also fixes edge_bfs_search and corrects the old undirected pattern that double-counted edges. Closing #1494 and #1507 as superseded. Thanks!

@kramerica-inc-dev

Copy link
Copy Markdown

Production data in support of merging this: we run graphiti-core 0.28.2 on FalkorDB (self-hosted memory service, one graph per group_id) and the re-MATCH pattern this PR removes was our single biggest failure source — dense-document episodes took 15–25 minutes or dead-lettered on Query timed out, even after raising FalkorDB's query timeouts to 900s.

Evidence gathered on 2026-07-14 (graph with ~2.6k Entity nodes):

  • GRAPH.EXPLAIN of the current query shape shows the pathology directly: per fulltext hit a Node By Label Scan (n:Entity) driving an Edge By Index Scan — O(hits × nodes). The rewritten shape (this PR's transform, which we backported as a build-time patch) has no scan operators at all: hits flow straight through Project/Filter.
  • GRAPH.PROFILE of the rewritten query: 572 fulltext hits resolved in under 2 ms end-to-end.
  • End-to-end effect after deploying the backport: the same documents that previously ground for 15–25 minutes (or parked in our dead-letter queue after repeated timeouts) now land in 1–4 minutes; a replay of 11 previously-parked episodes completed with zero timeouts.

We can also confirm the maintainer note above from runtime observation: search_utils.py is the path that actually executes (search_interface is never assigned), so patching both locations, as this PR does, is exactly right.

One gap we found while validating: the BFS variant of the same antipattern in search_utils.py (edge_bfs_search, the generic branch shared by FalkorDB and Neo4j) is not covered by this PR — its BFS hunk lands in falkordb/operations/search_ops.py only, which is not reached at runtime. We've opened #1653 applying this PR's own BFS transform to that live path, framed explicitly as completing this one. Related: we also filed #1652 (empty graph name crash) and #1651 (MCP episode tools querying the wrong graph) from the same production debugging session.

@Naseem77

Copy link
Copy Markdown
Contributor

@jaredloman The fulltext half checks out with identical results. But the public edge_bfs_search generic branch still re-MATCHes, your BFS rewrite landed only in the unused FalkorSearchOperations path. Moving it to search_utils.py would complete it.

@rschlek

rschlek commented Jul 21, 2026

Copy link
Copy Markdown

Independent production confirmation of both the bug and this fix, plus a data point on the BFS residual @Naseem77 flagged.

Setup: graphiti-core 0.29.2, FalkorDB, graph of ~10.4k entity nodes / ~7.7k RELATES_TO edges, ingest via add_episode.

Before: single edge_fulltext_search queries ran 10+ minutes; add_episode took ~45 min per episode with FalkorDB pinned at 300–450% CPU (single-threaded module saturated). GRAPH.EXPLAIN shows the {uuid: rel.uuid} re-MATCH scanning all edges per fulltext hit, as #1272 describes.

After applying this PR's startNode()/endNode() rewrite to graphiti_core/search/search_utils.py (the live path — confirming the consolidation rationale: patching only FalkorSearchOperations changed nothing for us, since search_interface is None for FalkorDB): the same queries return in seconds, FalkorDB idles at ~1% CPU, and ingest becomes purely LLM-bound.

On the BFS residual: confirmed — the generic edge_bfs_search in search_utils.py still re-MATCHes on the live path. We run the equivalent of this PR's rewrite there in production. One subtlety worth carrying into the fix: the BFS traversal is [:RELATES_TO|MENTIONS*1..n], so once the re-MATCH (which implicitly type-filtered) is removed, a type guard is required:

UNWIND relationships(path) AS rel
WITH DISTINCT rel AS e, startNode(rel) AS n, endNode(rel) AS m
WHERE type(e) = 'RELATES_TO'

(and any existing filter_query that begins with WHERE needs its first WHEREAND.) With that guard the rewrite is behavior-identical for us; without it, MENTIONS edges leak into results. Happy to share exact timings or the full diff if useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants