1147 lines
39 KiB
Python
1147 lines
39 KiB
Python
# 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="<Record node='dummy-node' score=1.0 node_id=123>",
|
|
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="<Record node='dummy-node' score=1.0 node_id=123>",
|
|
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 " <FUZZY_SLOP> "~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 " <FUZZY_SLOP> "~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
|