From c6eafb6812f6265cb93fe861113176e24dcb4913 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Thu, 25 Jun 2026 10:43:26 +0200 Subject: [PATCH] Support Neo4j 2026 vector SEARCH with CYPHER 25 and procedure fallback --- CHANGELOG.md | 4 +++ src/neo4j_graphrag/neo4j_queries.py | 17 ++++++++++-- src/neo4j_graphrag/retrievers/vector.py | 21 +++++++------- src/neo4j_graphrag/utils/version_utils.py | 13 +++++++++ tests/unit/retrievers/test_vector.py | 34 +++++++++++++++++++++++ tests/unit/test_neo4j_queries.py | 7 +++-- tests/unit/utils/test_version_utils.py | 24 ++++++++++++++++ 7 files changed, 104 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec165b972..70218f635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/neo4j_graphrag/neo4j_queries.py b/src/neo4j_graphrag/neo4j_queries.py index 1e1d6ec6d..ae07e3f3b 100644 --- a/src/neo4j_graphrag/neo4j_queries.py +++ b/src/neo4j_graphrag/neo4j_queries.py @@ -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) " @@ -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( @@ -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" ) @@ -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" ) diff --git a/src/neo4j_graphrag/retrievers/vector.py b/src/neo4j_graphrag/retrievers/vector.py index 60450319b..864d6a658 100644 --- a/src/neo4j_graphrag/retrievers/vector.py +++ b/src/neo4j_graphrag/retrievers/vector.py @@ -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, @@ -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( @@ -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( diff --git a/src/neo4j_graphrag/utils/version_utils.py b/src/neo4j_graphrag/utils/version_utils.py index c7fca0b92..4fc730c59 100644 --- a/src/neo4j_graphrag/utils/version_utils.py +++ b/src/neo4j_graphrag/utils/version_utils.py @@ -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 + ) diff --git a/tests/unit/retrievers/test_vector.py b/tests/unit/retrievers/test_vector.py index bbf54e1e2..5654a7872 100644 --- a/tests/unit/retrievers/test_vector.py +++ b/tests/unit/retrievers/test_vector.py @@ -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 diff --git a/tests/unit/test_neo4j_queries.py b/tests/unit/test_neo4j_queries.py index 43a820f57..a6998c0bc 100644 --- a/tests/unit/test_neo4j_queries.py +++ b/tests/unit/test_neo4j_queries.py @@ -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) " @@ -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 == {} @@ -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 == {} @@ -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 " @@ -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 diff --git a/tests/unit/utils/test_version_utils.py b/tests/unit/utils/test_version_utils.py index a44ec6543..62f7a9864 100644 --- a/tests/unit/utils/test_version_utils.py +++ b/tests/unit/utils/test_version_utils.py @@ -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, @@ -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, ) @@ -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 + )