# Copyright (c) "Neo4j" # Neo4j Sweden AB [https://neo4j.com] # # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # # https://www.apache.org/licenses/LICENSE-2.0 # # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from unittest.mock import MagicMock, patch import neo4j import pytest from neo4j_graphrag.exceptions import ( EmbeddingRequiredError, RetrieverInitializationError, SearchValidationError, SearchQueryParseError, ) from neo4j_graphrag.neo4j_queries import get_search_query from neo4j_graphrag.retrievers import HybridCypherRetriever, HybridRetriever from neo4j_graphrag.types import ( RetrieverResult, RetrieverResultItem, SearchType, HybridSearchRanker, ) def test_vector_retriever_initialization(driver: MagicMock) -> None: with patch("neo4j_graphrag.retrievers.base.get_version") as mock_get_version: mock_get_version.return_value = ((5, 23, 0), False, False) HybridRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", ) mock_get_version.assert_called_once() def test_vector_cypher_retriever_initialization(driver: MagicMock) -> None: with patch("neo4j_graphrag.retrievers.base.get_version") as mock_get_version: mock_get_version.return_value = ((5, 23, 0), False, False) HybridCypherRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", retrieval_query="", ) mock_get_version.assert_called_once() @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_invalid_fulltext_index_name( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) with pytest.raises(RetrieverInitializationError) as exc_info: HybridRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name=42, # type: ignore ) assert "fulltext_index_name" in str(exc_info.value) assert "Input should be a valid string" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_with_result_format_function( mock_get_version: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, result_formatter: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder, result_formatter=result_formatter, ) retriever.neo4j_version_is_5_23_or_above = True retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] records = retriever.search(query_text=query_text, top_k=top_k) assert records == RetrieverResult( items=[ RetrieverResultItem( content="dummy-node", metadata={"score": 1.0, "node_id": 123} ), ], metadata={"__retriever": "HybridRetriever", "query_vector": embed_query_vector}, ) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_invalid_database_name( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) with pytest.raises(RetrieverInitializationError) as exc_info: HybridRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", neo4j_database=42, # type: ignore ) assert "database" in str(exc_info.value) assert "Input should be a valid string" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retriever_invalid_retrieval_query( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) with pytest.raises(RetrieverInitializationError) as exc_info: HybridCypherRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", retrieval_query=42, # type: ignore ) assert "retrieval_query" in str(exc_info.value) assert "Input should be a valid string" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retriever_invalid_database_name( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) retrieval_query = """ RETURN node.id AS node_id, node.text AS text, score, {test: $param} AS metadata """ with pytest.raises(RetrieverInitializationError) as exc_info: HybridCypherRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", retrieval_query=retrieval_query, neo4j_database=42, # type: ignore ) assert "database" in str(exc_info.value) assert "Input should be a valid string" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_search_text_happy_path( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder ) retriever.neo4j_version_is_5_23_or_above = True retriever._embedding_node_property = ( "embedding" # variable normally filled by fetch_index_infos ) retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] search_query, _ = get_search_query( SearchType.HYBRID, embedding_node_property="embedding", neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) records = retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, ) retriever.driver.execute_query.assert_called_once_with( # type: ignore search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, }, database_=None, routing_=neo4j.RoutingControl.READ, ) embedder.embed_query.assert_called_once_with(query_text) assert records == RetrieverResult( items=[ RetrieverResultItem(content="dummy-node", metadata={"score": 1.0}), ], metadata={"__retriever": "HybridRetriever", "query_vector": embed_query_vector}, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_search_sanitizes_text( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = 'may thy knife chip and shatter+-&|!(){}[]^"~*?:\\/' top_k = 5 effective_search_ratio = 2 retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder ) retriever.neo4j_version_is_5_23_or_above = True retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, ) embedder.embed_query.assert_called_once_with(query_text) search_query, _ = get_search_query( SearchType.HYBRID, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) driver.execute_query.assert_called_once_with( search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, }, database_=None, routing_=neo4j.RoutingControl.READ, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_search_favors_query_vector_over_embedding_vector( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] query_vector = [2.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 database = "neo4j" retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder, neo4j_database=database, ) retriever.neo4j_version_is_5_23_or_above = True retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] search_query, _ = get_search_query( SearchType.HYBRID, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) retriever.search( query_text=query_text, query_vector=query_vector, top_k=top_k, effective_search_ratio=effective_search_ratio, ) retriever.driver.execute_query.assert_called_once_with( # type: ignore search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": query_vector, }, database_=database, routing_=neo4j.RoutingControl.READ, ) embedder.embed_query.assert_not_called() def test_error_when_hybrid_search_only_text_no_embedder( hybrid_retriever: HybridRetriever, ) -> None: query_text = "may thy knife chip and shatter" top_k = 5 with pytest.raises( EmbeddingRequiredError, match="Embedding method required for text query." ): hybrid_retriever.search( query_text=query_text, top_k=top_k, ) def test_hybrid_search_retriever_search_missing_embedder_for_text( hybrid_retriever: HybridRetriever, ) -> None: query_text = "may thy knife chip and shatter" top_k = 5 with pytest.raises( EmbeddingRequiredError, match="Embedding method required for text query" ): hybrid_retriever.search( query_text=query_text, top_k=top_k, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_return_properties( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 return_properties = ["node-property-1", "node-property-2"] retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder, return_properties, ) retriever.neo4j_version_is_5_23_or_above = True driver.execute_query.return_value = [ [neo4j_record], None, None, ] search_query, _ = get_search_query( search_type=SearchType.HYBRID, return_properties=return_properties, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) records = retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, ) embedder.embed_query.assert_called_once_with(query_text) driver.execute_query.assert_called_once_with( search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, }, database_=None, routing_=neo4j.RoutingControl.READ, ) assert records == RetrieverResult( items=[ RetrieverResultItem(content="dummy-node", metadata={"score": 1.0}), ], metadata={"__retriever": "HybridRetriever", "query_vector": embed_query_vector}, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retrieval_query_with_params( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 retrieval_query = """ RETURN node.id AS node_id, node.text AS text, score, {test: $param} AS metadata """ query_params = { "param": "dummy-param", } retriever = HybridCypherRetriever( driver, vector_index_name, fulltext_index_name, retrieval_query, embedder, ) retriever.neo4j_version_is_5_23_or_above = True driver.execute_query.return_value = [ [neo4j_record], None, None, ] search_query, _ = get_search_query( search_type=SearchType.HYBRID, retrieval_query=retrieval_query, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) records = retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, query_params=query_params, ) embedder.embed_query.assert_called_once_with(query_text) driver.execute_query.assert_called_once_with( search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, "param": "dummy-param", }, database_=None, routing_=neo4j.RoutingControl.READ, ) assert records == RetrieverResult( items=[ RetrieverResultItem( content="", metadata=None, ), ], metadata={ "__retriever": "HybridCypherRetriever", "query_vector": embed_query_vector, }, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retriever_with_result_format_function( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, result_formatter: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 retriever = HybridCypherRetriever( driver, vector_index_name, fulltext_index_name, "", embedder, result_formatter=result_formatter, ) retriever.neo4j_version_is_5_23_or_above = True retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] records = retriever.search(query_text=query_text, top_k=top_k) assert records == RetrieverResult( items=[ RetrieverResultItem( content="dummy-node", metadata={"score": 1.0, "node_id": 123} ), ], metadata={ "__retriever": "HybridCypherRetriever", "query_vector": embed_query_vector, }, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_search_sanitizes_text( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = 'may thy knife chip and shatter+-&|!(){}[]^"~*?:\\/' top_k = 5 effective_search_ratio = 2 retrieval_query = """ RETURN node.id AS node_id, node.text AS text, score, {test: $param} AS metadata """ retriever = HybridCypherRetriever( driver, vector_index_name, fulltext_index_name, retrieval_query, embedder, ) retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, ) embedder.embed_query.assert_called_once_with(query_text) search_query, _ = get_search_query( SearchType.HYBRID, retrieval_query=retrieval_query, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ) driver.execute_query.assert_called_once_with( search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, }, database_=None, routing_=neo4j.RoutingControl.READ, ) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_linear_without_alpha( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) with pytest.raises(SearchValidationError) as exc_info: HybridRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", neo4j_database="neo4j", ).search(query_text="test query", ranker="linear") assert "alpha must be provided" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retriever_linear_without_alpha( mock_get_version: MagicMock, driver: MagicMock ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) with pytest.raises(SearchValidationError) as exc_info: HybridCypherRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", neo4j_database="neo4j", retrieval_query="", ).search(query_text="test query", ranker="linear") assert "alpha must be provided" in str(exc_info.value) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_search_linear_ranker_happy_path( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 ranker = HybridSearchRanker.LINEAR alpha = 0.7 retriever = HybridRetriever( driver, vector_index_name, fulltext_index_name, embedder ) retriever.neo4j_version_is_5_23_or_above = True retriever._embedding_node_property = "embedding" retriever.driver.execute_query.return_value = [ # type: ignore [neo4j_record], None, None, ] search_query, _ = get_search_query( SearchType.HYBRID, embedding_node_property="embedding", neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ranker=ranker, alpha=alpha, ) records = retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, ranker=ranker, alpha=alpha, ) retriever.driver.execute_query.assert_called_once_with( # type: ignore search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, "alpha": alpha, }, database_=None, routing_=neo4j.RoutingControl.READ, ) embedder.embed_query.assert_called_once_with(query_text) assert records == RetrieverResult( items=[ RetrieverResultItem(content="dummy-node", metadata={"score": 1.0}), ], metadata={"__retriever": "HybridRetriever", "query_vector": embed_query_vector}, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_linear_ranker( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, neo4j_record: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) embed_query_vector = [1.0 for _ in range(1536)] embedder.embed_query.return_value = embed_query_vector vector_index_name = "vector-index" fulltext_index_name = "fulltext-index" query_text = "may thy knife chip and shatter" top_k = 5 effective_search_ratio = 2 ranker = HybridSearchRanker.LINEAR alpha = 0.7 retrieval_query = """ RETURN node.id AS node_id, node.text AS text, score, {test: $param} AS metadata """ query_params = { "param": "dummy-param", } retriever = HybridCypherRetriever( driver, vector_index_name, fulltext_index_name, retrieval_query, embedder, ) retriever.neo4j_version_is_5_23_or_above = True driver.execute_query.return_value = [ [neo4j_record], None, None, ] search_query, _ = get_search_query( search_type=SearchType.HYBRID, retrieval_query=retrieval_query, neo4j_version_is_5_23_or_above=retriever.neo4j_version_is_5_23_or_above, ranker=ranker, alpha=alpha, ) records = retriever.search( query_text=query_text, top_k=top_k, effective_search_ratio=effective_search_ratio, query_params=query_params, ranker=ranker, alpha=alpha, ) embedder.embed_query.assert_called_once_with(query_text) driver.execute_query.assert_called_once_with( search_query, { "vector_index_name": vector_index_name, "top_k": top_k, "effective_search_ratio": effective_search_ratio, "query_text": query_text, "fulltext_index_name": fulltext_index_name, "query_vector": embed_query_vector, "param": "dummy-param", "alpha": alpha, }, database_=None, routing_=neo4j.RoutingControl.READ, ) assert records == RetrieverResult( items=[ RetrieverResultItem( content="", metadata=None, ), ], metadata={ "__retriever": "HybridCypherRetriever", "query_vector": embed_query_vector, }, ) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_retriever_invalid_lucene_query_error( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) error_message = ( "Failed to invoke procedure `db.index.fulltext.queryNodes`: " "Caused by: org.apache.lucene.queryparser.classic.ParseException: " 'Encountered " "~aliens " at line 1, column 0.' ) client_error = neo4j.exceptions.ClientError(error_message) driver.execute_query.side_effect = client_error retriever = HybridRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", embedder=embedder, ) retriever.neo4j_version_is_5_23_or_above = True retriever._embedding_node_property = "embedding" with pytest.raises( SearchQueryParseError, match="Invalid Lucene query generated from query_text" ): retriever.search(query_text="~aliens", top_k=5) @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=False) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_hybrid_cypher_retriever_invalid_lucene_query_error( mock_get_version: MagicMock, _fetch_index_infos_mock: MagicMock, _mock_search_clause: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((5, 23, 0), False, False) retrieval_query = """ RETURN node.id AS node_id, node.text AS text, score, {test: $param} AS metadata """ error_message = ( "Failed to invoke procedure `db.index.fulltext.queryNodes`: " "Caused by: org.apache.lucene.queryparser.classic.ParseException: " 'Encountered " "~aliens " at line 1, column 0.' ) client_error = neo4j.exceptions.ClientError(error_message) driver.execute_query.side_effect = client_error retriever = HybridCypherRetriever( driver=driver, vector_index_name="vector-index", fulltext_index_name="fulltext-index", embedder=embedder, retrieval_query=retrieval_query, ) retriever.neo4j_version_is_5_23_or_above = True retriever._embedding_node_property = "embedding" with pytest.raises( SearchQueryParseError, match="Invalid Lucene query generated from query_text" ): retriever.search(query_text="~aliens", top_k=5) # --- SEARCH clause routing tests --- class TestHybridRetrieverSearchClausePath: """Tests for HybridRetriever routing to SEARCH clause on Neo4j 2026.01+.""" @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_uses_search_clause_naive_ranker( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retriever = HybridRetriever(driver, "vector-index", "fulltext-index", embedder) retriever._node_label = "Document" retriever._embedding_node_property = "embedding" driver.execute_query.return_value = [[], None, None] retriever.search(query_text="test query", top_k=5) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "CALL () {" in executed_query assert "VECTOR INDEX" in executed_query assert "db.index.fulltext.queryNodes" in executed_query assert "SEARCH node IN" in executed_query assert "db.index.vector.queryNodes" not in executed_query @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_uses_search_clause_linear_ranker( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retriever = HybridRetriever(driver, "vector-index", "fulltext-index", embedder) retriever._node_label = "Document" retriever._embedding_node_property = "embedding" driver.execute_query.return_value = [[], None, None] retriever.search( query_text="test query", top_k=5, ranker=HybridSearchRanker.LINEAR, alpha=0.7, ) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "CALL () {" in executed_query assert "SEARCH node IN" in executed_query assert "sum(score)" in executed_query @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_falls_back_when_no_node_label( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retriever = HybridRetriever(driver, "vector-index", "fulltext-index", embedder) retriever._node_label = None driver.execute_query.return_value = [[], None, None] retriever.search(query_text="test query", top_k=5) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "SEARCH node IN" not in executed_query assert "db.index.vector.queryNodes" in executed_query @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_search_clause_with_return_properties( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retriever = HybridRetriever( driver, "vector-index", "fulltext-index", embedder, return_properties=["name", "text"], ) retriever._node_label = "Document" retriever._embedding_node_property = "embedding" driver.execute_query.return_value = [[], None, None] retriever.search(query_text="test query", top_k=5) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "SEARCH node IN" in executed_query assert ".name" in executed_query assert ".text" in executed_query class TestHybridCypherRetrieverSearchClausePath: """Tests for HybridCypherRetriever routing to SEARCH clause.""" @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_uses_search_clause_with_retrieval_query( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retrieval_query = "RETURN node.id AS node_id, score" retriever = HybridCypherRetriever( driver, "vector-index", "fulltext-index", retrieval_query, embedder, ) retriever._node_label = "Document" driver.execute_query.return_value = [[], None, None] retriever.search(query_text="test query", top_k=5) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "SEARCH node IN" in executed_query assert "VECTOR INDEX" in executed_query assert "db.index.fulltext.queryNodes" in executed_query assert retrieval_query in executed_query @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_falls_back_when_no_node_label( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retrieval_query = "RETURN node.id AS node_id, score" retriever = HybridCypherRetriever( driver, "vector-index", "fulltext-index", retrieval_query, embedder, ) retriever._node_label = None driver.execute_query.return_value = [[], None, None] retriever.search(query_text="test query", top_k=5) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "SEARCH node IN" not in executed_query @patch("neo4j_graphrag.retrievers.hybrid.supports_search_clause", return_value=True) @patch("neo4j_graphrag.retrievers.HybridCypherRetriever._fetch_index_infos") @patch("neo4j_graphrag.retrievers.base.get_version") def test_search_clause_linear_ranker( self, mock_get_version: MagicMock, _fetch_index_infos: MagicMock, _mock_supports_search: MagicMock, driver: MagicMock, embedder: MagicMock, ) -> None: mock_get_version.return_value = ((2026, 1, 0), False, True) embed_query_vector = [1.0, 2.0, 3.0] embedder.embed_query.return_value = embed_query_vector retrieval_query = "RETURN node.id AS node_id, score" retriever = HybridCypherRetriever( driver, "vector-index", "fulltext-index", retrieval_query, embedder, ) retriever._node_label = "Document" driver.execute_query.return_value = [[], None, None] retriever.search( query_text="test query", top_k=5, ranker=HybridSearchRanker.LINEAR, alpha=0.7, ) call_args = driver.execute_query.call_args executed_query = call_args[0][0] assert "SEARCH node IN" in executed_query assert "sum(score)" in executed_query