Skip to content

fix(falkordb): hoist LIMIT before per-row MATCH to avoid query timeout#1507

Closed
brentkearney wants to merge 2 commits into
getzep:mainfrom
brentkearney:fix/falkordb-edge-search-timeout
Closed

fix(falkordb): hoist LIMIT before per-row MATCH to avoid query timeout#1507
brentkearney wants to merge 2 commits into
getzep:mainfrom
brentkearney:fix/falkordb-edge-search-timeout

Conversation

@brentkearney

@brentkearney brentkearney commented May 25, 2026

Copy link
Copy Markdown

Closes #1506.

Summary

Fix a FalkorDB-specific query-timeout bug in edge_fulltext_search that causes silent ingestion failures on non-trivially-sized graphs.

The defective Cypher template exists in two functions: the provider-agnostic edge_fulltext_search in graphiti_core/search/search_utils.py (the path exercised during episode ingestion and dedup) and the FalkorDB driver's copy in graphiti_core/driver/falkordb/operations/search_ops.py. Both apply their LIMIT after a per-row MATCH re-lookup of every relationship returned by db.idx.fulltext.queryRelationships. With a broad-tokened OR query the fulltext call yields hundreds of candidate edges, the planner runs the MATCH for each one, and total execution exceeds FalkorDB's default 1-second module timeout. The fulltext call itself is sub-millisecond; the per-row MATCH dominates the cost.

This PR hoists ORDER BY score / LIMIT between YIELD and the per-row MATCH in both functions, with a ×4 multiplier so the post-MATCH WHERE clause (which can filter on node labels via n/m) still has enough candidates to fill the requested limit in the common case.

Neo4j and Kuzu are unaffected — Neo4j's db.index.fulltext.queryRelationships accepts {limit: $limit} inline (graph_queries.py:175) and Kuzu uses TOP := $limit, so the limit is already applied inside the procedure. The hoist is gated to the FalkorDB provider in both functions.

Changes

  • graphiti_core/search/search_utils.py — in edge_fulltext_search, add a FalkorDB branch to match_query placing WITH rel, score / ORDER BY score DESC / LIMIT $limit_with_buffer between YIELD and MATCH; pass limit_with_buffer = limit * 4 (FalkorDB only). This is the path used during ingestion/dedup.
  • graphiti_core/driver/falkordb/operations/search_ops.py — the same hoist in the driver's edge_fulltext_search copy.

Both keep the final ORDER BY score DESC / LIMIT $limit after the WHERE, so each returns exactly $limit results. No changes to public API, return shape, or behavior on the Neo4j / Kuzu / Neptune paths.

Reproduction & benchmark

Against a FalkorDB graph with 1,117 nodes and 909 RELATES_TO edges (FalkorDB 4.18.8, Redis 8.6.3), running the captured failing query:

(@group_id:"<gid>") (GeminiEmbedder | fix | works | zip | error | appears | graphiti | graphiti | mcp | 1 | logs)
Cypher shape Time Outcome
db.idx.fulltext.queryRelationships(...) alone (count + max score) 0.3 ms Baseline — fulltext index is fast.
Current Cypher (LIMIT after MATCH) timeout @ 1000 ms Bug.
Current Cypher with TIMEOUT_DEFAULT=30000 1382 ms Completes but pathologically slow.
Fixed Cypher (LIMIT before MATCH, ×4 multiplier) 238 ms ~5× faster than (3), well under any reasonable timeout.

End-to-end verified through the live search_utils.py ingestion/dedup path: an MCP add_memory call that previously dropped silently with Query timed out now completes ingestion successfully (entity/edge extraction, embeddings, dedup, summarization), and the edge-dedup search that drives temporal invalidation runs without FalkorDB timeouts.

Why ×4 multiplier rather than ×1

edge_search_filter_query_constructor (graphiti_core/search/search_filters.py:120) can append filters that reference n/m (node label filter at :146: n:label AND m:label), so the WHERE clause must run after MATCH. The multiplier ensures the WHERE has enough candidates to fill $limit in the common case.

×4 is conservative — at the default limit=10, we pull 40 fulltext candidates and run 40 MATCHes (~5 ms each = ~200 ms). If filters are very narrow and reduce >75%, the function returns fewer than $limit — same behavior as today when filters happen to filter most matches out. No regression vs. current semantics; just a cap on how many candidates we examine.

If maintainers prefer a different multiplier or want it configurable, happy to adjust.

Tests

  • All existing tests/test_search.py and FalkorDB integration tests should pass unchanged. (CI will confirm.)
  • Manually verified against the live reproduction described above — query that previously timed out now completes in ~238 ms returning the expected top-scored edges.

Out of scope (potential follow-ups)

  1. edge_bfs_search (search_ops.py:413-428) has a similar per-row MATCH-after-YIELD shape but the upstream YIELD comes from a path expansion rather than fulltext, so the planner behavior differs. Worth auditing in a separate PR.
  2. Silent ingestion failure on queue errors. services.queue_service logs the timeout but add_memory still returns "queued for processing" to the caller — same shape as the recently-fixed GeminiEmbedder batch silent drop. Worth surfacing queue errors back to the caller, but a separate concern.
  3. Wasted dedup search when valid_edges is empty. Callers in graphiti.py:1727 and utils/maintenance/edge_operations.py:399 construct SearchFilters(edge_uuids=[edge.uuid for edge in valid_edges]); when valid_edges is empty this becomes edge_uuids=[], which produces WHERE e.uuid IN [] — always false. Result is semantically correct (no related edges to dedup against) but wastes the fulltext lookup + per-row MATCH + reranker LLM call. After this PR the wasted query is fast, but the cleanest fix is at the call sites: skip the search entirely when valid_edges is empty. Worth a separate perf-opt PR; tempting in-constructor fixes (truthiness check) actually break dedup semantics because they make the search unconstrained instead of returning empty.

@brentkearney
brentkearney force-pushed the fix/falkordb-edge-search-timeout branch from fa3b74f to 6af7117 Compare May 25, 2026 06:08
@brentkearney brentkearney changed the title fix(falkordb): hoist LIMIT before per-row MATCH in edge_fulltext_search to avoid query timeout fix(falkordb): hoist LIMIT before per-row MATCH to avoid query timeout May 25, 2026
BaiYouQing added a commit to BaiYouQing/graphiti that referenced this pull request May 25, 2026
…korDB fulltext query

Two fixes for FalkorDB search operations:

1. **PR getzep#1507 - edge_fulltext_search LIMIT optimization**: Hoist the LIMIT clause
   before per-row MATCH to prevent FalkorDB timeout when fulltext search returns
   hundreds of candidates. Apply 4x buffer so post-MATCH WHERE filters still fill
   the requested limit.

2. **Issue getzep#1269 - Remove group_id from fulltext query**: FalkorDB treats hyphens
   as NOT operators even inside double quotes, which breaks group_ids containing
   hyphens. The group_id filter is already applied at the Cypher level, making
   the fulltext query filter redundant and harmful.
@danielchalef

Copy link
Copy Markdown
Member

Thanks for this, and for the thorough benchmarking. Consolidating on #1500, which removes the per-row re-MATCH entirely via startNode(e)/endNode(e) rather than bounding it with a LIMIT hoist (limit*4) — once the expensive MATCH is gone there's nothing left to bound, and the buffer heuristic can under-return when node-label filters are selective. Closing as superseded by #1500.

@zep-cla-assistant zep-cla-assistant Bot locked and limited conversation to collaborators Jun 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] FalkorDB edge fulltext search times out on broad queries

2 participants