Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Next

### Fixed

- Vector and VectorCypher retrievers on Neo4j 2026+: prefix SEARCH queries with `CYPHER 25` and fall back to procedure-based vector search when SEARCH is unsupported or fails.

## 1.18.0

### Changed
Expand Down
17 changes: 14 additions & 3 deletions src/neo4j_graphrag/neo4j_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
)
from neo4j_graphrag.types import EntityType, SearchType, HybridSearchRanker

CYPHER_25_PREFIX = "CYPHER 25 "


def prefix_cypher_25(query: str) -> str:
"""Prefix a query so Cypher 25 features work on Cypher 5 default databases."""
normalized = query.lstrip()
if normalized.upper().startswith("CYPHER 25"):
return query
return f"{CYPHER_25_PREFIX}{query}"


NODE_VECTOR_INDEX_QUERY = (
"CALL db.index.vector.queryNodes"
"($vector_index_name, $top_k * $effective_search_ratio, $query_vector) "
Expand Down Expand Up @@ -336,7 +347,7 @@ def _build_search_clause_vector_query(
f"SCORE AS score "
"WITH node, score ORDER BY score DESC LIMIT $top_k"
)
return query, params
return prefix_cypher_25(query), params


def _build_hybrid_search_clause_query(
Expand Down Expand Up @@ -376,7 +387,7 @@ def _build_hybrid_search_clause_query(
"UNWIND nodes AS n "
"RETURN n.node AS node, (n.score / ft_index_max_score) AS score"
)
return (
return prefix_cypher_25(
f"CALL () {{ {vector_part} UNION {fulltext_part} }} "
"WITH node, max(score) AS score ORDER BY score DESC LIMIT $top_k"
)
Expand Down Expand Up @@ -421,7 +432,7 @@ def _build_hybrid_search_clause_query_linear(
"WITH n.node AS node, (n.score / ft_index_max_score) AS rawScore "
"RETURN node, rawScore * (1 - $alpha) AS score"
)
return (
return prefix_cypher_25(
f"CALL () {{ {vector_part} UNION {fulltext_part} }} "
"WITH node, sum(score) AS score ORDER BY score DESC LIMIT $top_k"
)
Expand Down
21 changes: 10 additions & 11 deletions src/neo4j_graphrag/retrievers/vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
get_search_query,
)
from neo4j_graphrag.retrievers.base import Retriever
from neo4j_graphrag.utils.version_utils import supports_search_clause
from neo4j_graphrag.utils.version_utils import (
should_fallback_from_search_clause,
supports_search_clause,
)
from neo4j_graphrag.types import (
EmbedderModel,
Neo4jDriverModel,
Expand Down Expand Up @@ -285,12 +288,10 @@ def get_search_results(
routing_=neo4j.RoutingControl.READ,
)
except neo4j.exceptions.ClientError as e:
if use_search_clause and "PropertyNotFound" in str(e):
if use_search_clause and should_fallback_from_search_clause(e):
logger.warning(
"SEARCH clause failed (filtered property not declared as "
"filterable on the index); falling back to procedure-based "
"vector search. To use in-index filtering, recreate the "
"index with filterable_properties. Error: %s",
"SEARCH clause failed; falling back to procedure-based "
"vector search. Error: %s",
e,
)
search_query, search_params = get_search_query(
Expand Down Expand Up @@ -522,12 +523,10 @@ def get_search_results(
routing_=neo4j.RoutingControl.READ,
)
except neo4j.exceptions.ClientError as e:
if use_search_clause and "PropertyNotFound" in str(e):
if use_search_clause and should_fallback_from_search_clause(e):
logger.warning(
"SEARCH clause failed (filtered property not declared as "
"filterable on the index); falling back to procedure-based "
"vector search. To use in-index filtering, recreate the "
"index with filterable_properties. Error: %s",
"SEARCH clause failed; falling back to procedure-based "
"vector search. Error: %s",
e,
)
search_query, search_params = get_search_query(
Expand Down
13 changes: 13 additions & 0 deletions src/neo4j_graphrag/utils/version_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,16 @@ def supports_search_clause(
)
return False
return version_tuple >= (2026, 1, 0)


def should_fallback_from_search_clause(error: BaseException) -> bool:
"""Return True when a SEARCH-clause query should retry with procedures."""
if not isinstance(error, neo4j.exceptions.ClientError):
return False
message = str(error)
return (
"PropertyNotFound" in message
or "Invalid input 'SEARCH'" in message
or "CYPHER 25" in message
or "42I67" in message
)
34 changes: 34 additions & 0 deletions tests/unit/retrievers/test_vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,3 +875,37 @@ def test_search_clause_with_compatible_filters(
executed_query = call_args[0][0]
assert "SEARCH node IN (VECTOR INDEX" in executed_query
assert "WHERE" in executed_query

@patch("neo4j_graphrag.retrievers.vector.supports_search_clause", return_value=True)
@patch("neo4j_graphrag.retrievers.VectorCypherRetriever._fetch_index_infos")
@patch("neo4j_graphrag.retrievers.base.get_version")
def test_falls_back_when_search_clause_unsupported_by_database(
self,
mock_get_version: MagicMock,
_fetch_index_infos: MagicMock,
_mock_supports_search: MagicMock,
driver: MagicMock,
) -> None:
mock_get_version.return_value = ((2026, 1, 0), False, True)
retrieval_query = "RETURN node.title AS title, score"

retriever = VectorCypherRetriever(
driver=driver,
index_name="my-index",
retrieval_query=retrieval_query,
)
retriever._node_label = "Movie"

driver.execute_query.side_effect = [
neo4j.exceptions.ClientError(
"Invalid input 'SEARCH': expected a graph pattern"
),
([], None, None),
]

retriever.search(query_vector=[1.0, 2.0, 3.0], top_k=5)

assert driver.execute_query.call_count == 2
fallback_query = driver.execute_query.call_args_list[1][0][0]
assert "db.index.vector.queryNodes" in fallback_query
assert "RETURN node.title AS title, score" in fallback_query
7 changes: 5 additions & 2 deletions tests/unit/test_neo4j_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def test_no_filters(self) -> None:
node_label="Document",
)
expected = (
"MATCH (node:`Document`) "
"CYPHER 25 MATCH (node:`Document`) "
"SEARCH node IN (VECTOR INDEX `my_index` "
"FOR $query_vector "
"LIMIT $top_k * $effective_search_ratio) "
Expand All @@ -294,6 +294,7 @@ def test_no_filters_with_none_classification(self) -> None:
node_label="Document",
filter_classification=None,
)
assert query.startswith("CYPHER 25 ")
assert "WHERE" not in query
assert params == {}

Expand All @@ -304,6 +305,7 @@ def test_empty_filter_classification(self) -> None:
node_label="Document",
filter_classification=classification,
)
assert query.startswith("CYPHER 25 ")
assert "WHERE" not in query
assert params == {}

Expand All @@ -319,7 +321,7 @@ def test_with_where_predicates(self) -> None:
filter_classification=classification,
)
expected = (
"MATCH (node:`Document`) "
"CYPHER 25 MATCH (node:`Document`) "
"SEARCH node IN (VECTOR INDEX `my_index` "
"FOR $query_vector "
"WHERE node.`year` = $_e_year "
Expand Down Expand Up @@ -394,6 +396,7 @@ def test_basic_structure(self) -> None:
fulltext_index_name="ft_idx",
node_label="Document",
)
assert query.startswith("CYPHER 25 ")
assert "CALL () {" in query
assert "UNION" in query
assert "VECTOR INDEX `vec_idx`" in query
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/utils/test_version_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.
from unittest.mock import MagicMock

import neo4j
import pytest
from neo4j_graphrag.utils.version_utils import (
clear_version_cache,
Expand All @@ -22,6 +23,7 @@
has_vector_index_support,
has_metadata_filtering_support,
is_version_5_23_or_above,
should_fallback_from_search_clause,
supports_search_clause,
)

Expand Down Expand Up @@ -203,3 +205,25 @@ def test_uses_cache(self, driver: MagicMock) -> None:
supports_search_clause(driver)
supports_search_clause(driver)
assert driver.execute_query.call_count == 1


@pytest.mark.parametrize(
("message", "expected"),
[
("PropertyNotFound: foo", True),
("Invalid input 'SEARCH'", True),
("parsable in `CYPHER 25`", True),
("42I67: unsupported language feature", True),
("Some other client error", False),
],
)
def test_should_fallback_from_search_clause(message: str, expected: bool) -> None:
error = neo4j.exceptions.ClientError(message)
assert should_fallback_from_search_clause(error) is expected


def test_should_fallback_from_search_clause_ignores_non_client_errors() -> None:
assert (
should_fallback_from_search_clause(ValueError("Invalid input 'SEARCH'"))
is False
)
Loading