From 710325c05279c11be22241177d3166b9546cafb3 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 10:42:26 +0200 Subject: [PATCH 01/20] Add explain models --- src/neo4j_graphrag/generation/__init__.py | 25 +++++++- src/neo4j_graphrag/generation/explain.py | 78 +++++++++++++++++++++++ uv.lock | 2 +- 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/neo4j_graphrag/generation/explain.py diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 816fe327a..f440c5bf3 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -1,4 +1,27 @@ +from .explain import ( + ExplainConfig, + ExplainResult, + GraphContext, + GraphNodeRef, + GraphRelationshipRef, + GraphPath, + SourceRef, + TraceStep, +) from .graphrag import GraphRAG from .prompts import PromptTemplate, RagTemplate, SchemaExtractionTemplate -__all__ = ["GraphRAG", "PromptTemplate", "RagTemplate", "SchemaExtractionTemplate"] +__all__ = [ + "ExplainConfig", + "ExplainResult", + "GraphContext", + "GraphNodeRef", + "GraphPath", + "GraphRelationshipRef", + "GraphRAG", + "PromptTemplate", + "RagTemplate", + "SchemaExtractionTemplate", + "SourceRef", + "TraceStep", +] diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py new file mode 100644 index 000000000..993c5e296 --- /dev/null +++ b/src/neo4j_graphrag/generation/explain.py @@ -0,0 +1,78 @@ +# 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 __future__ import annotations + +from typing import Any, Union + +from pydantic import BaseModel, Field, PositiveInt + +GraphPathElement = Union["GraphNodeRef", "GraphRelationshipRef"] +GraphPath = list[GraphPathElement] + + +class ExplainConfig(BaseModel): + """Configuration for GraphRAG explainability output.""" + + cite_sources: bool = True + + +class GraphNodeRef(BaseModel): + """A node referenced in explainability graph context.""" + + id: str | None = None + labels: list[str] = Field(default_factory=list) + properties: dict[str, Any] = Field(default_factory=dict) + + +class GraphRelationshipRef(BaseModel): + """A relationship referenced in explainability graph context.""" + + type: str + start_id: str | None = None + end_id: str | None = None + + +class GraphContext(BaseModel): + """Graph neighborhood for a single retrieved source.""" + + seed_node: GraphNodeRef | None = None + related_nodes: list[GraphNodeRef] = Field(default_factory=list) + relationships: list[GraphRelationshipRef] = Field(default_factory=list) + paths: list[GraphPath] = Field(default_factory=list) + + +class SourceRef(BaseModel): + """A single source item aligned with LLM context numbering.""" + + index: PositiveInt + content: str + score: float | None = None + node_id: str | None = None + labels: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class TraceStep(BaseModel): + """Minimal execution trace for a GraphRAG search.""" + + retriever: str + + +class ExplainResult(BaseModel): + """Structured provenance for a GraphRAG answer.""" + + sources: list[SourceRef] + trace: TraceStep + graph: list[GraphContext] | None = None diff --git a/uv.lock b/uv.lock index 6865845f0..e4e0a23f4 100644 --- a/uv.lock +++ b/uv.lock @@ -2974,7 +2974,7 @@ wheels = [ [[package]] name = "neo4j-graphrag" -version = "1.15.0" +version = "1.17.0" source = { editable = "." } dependencies = [ { name = "fsspec" }, From a5b3ec7271947dc831ec3c01cbb820fb13271aa0 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 10:50:03 +0200 Subject: [PATCH 02/20] Add explain builders and unit tests --- src/neo4j_graphrag/generation/__init__.py | 10 +- src/neo4j_graphrag/generation/explain.py | 194 +++++++++++++++++++++- tests/unit/test_explain.py | 158 ++++++++++++++++++ 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_explain.py diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index f440c5bf3..6f6d6fcc5 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -3,10 +3,14 @@ ExplainResult, GraphContext, GraphNodeRef, - GraphRelationshipRef, GraphPath, + GraphRelationshipRef, SourceRef, TraceStep, + build_explain_result, + graph_from_retriever, + sources_from_retriever, + trace_from_retriever, ) from .graphrag import GraphRAG from .prompts import PromptTemplate, RagTemplate, SchemaExtractionTemplate @@ -24,4 +28,8 @@ "SchemaExtractionTemplate", "SourceRef", "TraceStep", + "build_explain_result", + "graph_from_retriever", + "sources_from_retriever", + "trace_from_retriever", ] diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 993c5e296..f9b199bd6 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -14,10 +14,13 @@ # limitations under the License. from __future__ import annotations -from typing import Any, Union +from typing import TYPE_CHECKING, Any, Union from pydantic import BaseModel, Field, PositiveInt +if TYPE_CHECKING: + from neo4j_graphrag.types import RetrieverResult + GraphPathElement = Union["GraphNodeRef", "GraphRelationshipRef"] GraphPath = list[GraphPathElement] @@ -76,3 +79,192 @@ class ExplainResult(BaseModel): sources: list[SourceRef] trace: TraceStep graph: list[GraphContext] | None = None + + +_SOURCE_METADATA_KEYS = frozenset( + {"score", "id", "node_id", "element_id", "nodeLabels", "labels", "graph", "paths"} +) + + +def trace_from_retriever(retriever_result: RetrieverResult) -> TraceStep: + from neo4j_graphrag.types import RetrieverResult as RetrieverResultType + + if not isinstance(retriever_result, RetrieverResultType): + raise TypeError("retriever_result must be a RetrieverResult") + metadata = retriever_result.metadata or {} + retriever = metadata.get("__retriever", "unknown") + return TraceStep(retriever=str(retriever)) + + +def sources_from_retriever(retriever_result: RetrieverResult) -> list[SourceRef]: + from neo4j_graphrag.types import RetrieverResult as RetrieverResultType + + if not isinstance(retriever_result, RetrieverResultType): + raise TypeError("retriever_result must be a RetrieverResult") + + sources: list[SourceRef] = [] + for index, item in enumerate(retriever_result.items, start=1): + metadata = item.metadata or {} + labels = metadata.get("labels") or metadata.get("nodeLabels") or [] + if not isinstance(labels, list): + labels = [str(labels)] + node_id = ( + metadata.get("node_id") or metadata.get("id") or metadata.get("element_id") + ) + extra_metadata = { + key: value + for key, value in metadata.items() + if key not in _SOURCE_METADATA_KEYS + } + sources.append( + SourceRef( + index=index, + content=str(item.content), + score=_coerce_float(metadata.get("score")), + node_id=str(node_id) if node_id is not None else None, + labels=labels, + metadata=extra_metadata, + ) + ) + return sources + + +def graph_from_retriever( + retriever_result: RetrieverResult, +) -> list[GraphContext] | None: + from neo4j_graphrag.types import RetrieverResult as RetrieverResultType + + if not isinstance(retriever_result, RetrieverResultType): + raise TypeError("retriever_result must be a RetrieverResult") + + contexts: list[GraphContext] = [] + for item in retriever_result.items: + context = _graph_context_from_item_metadata(item.metadata) + if context is not None: + contexts.append(context) + return contexts or None + + +def build_explain_result(retriever_result: RetrieverResult) -> ExplainResult: + return ExplainResult( + sources=sources_from_retriever(retriever_result), + trace=trace_from_retriever(retriever_result), + graph=graph_from_retriever(retriever_result), + ) + + +def _coerce_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _graph_context_from_item_metadata( + metadata: dict[str, Any] | None, +) -> GraphContext | None: + if not metadata: + return None + + graph_data = metadata.get("graph") + if isinstance(graph_data, GraphContext): + return graph_data + if isinstance(graph_data, dict): + return _graph_context_from_dict(graph_data, metadata.get("paths")) + + paths_data = metadata.get("paths") + if paths_data: + return GraphContext(paths=_parse_paths(paths_data)) + return None + + +def _graph_context_from_dict( + data: dict[str, Any], + extra_paths: Any = None, +) -> GraphContext: + seed = data.get("seed_node") + paths_data = data.get("paths", extra_paths) + return GraphContext( + seed_node=_parse_node_ref(seed) if seed else None, + related_nodes=[ + _parse_node_ref(node) + for node in data.get("related_nodes", []) + if node is not None + ], + relationships=[ + _parse_relationship_ref(rel) + for rel in data.get("relationships", []) + if rel is not None + ], + paths=_parse_paths(paths_data), + ) + + +def _parse_paths(paths_data: Any) -> list[GraphPath]: + if not paths_data: + return [] + if not isinstance(paths_data, list): + return [] + paths: list[GraphPath] = [] + for path in paths_data: + if not isinstance(path, list): + continue + elements = [_parse_path_element(element) for element in path] + paths.append(elements) + return paths + + +def _parse_path_element(element: Any) -> GraphPathElement: + if isinstance(element, GraphNodeRef): + return element + if isinstance(element, GraphRelationshipRef): + return element + if not isinstance(element, dict): + raise ValueError("path element must be a mapping or graph ref model") + if _is_relationship_mapping(element): + return _parse_relationship_ref(element) + return _parse_node_ref(element) + + +def _is_relationship_mapping(data: dict[str, Any]) -> bool: + return "type" in data and "labels" not in data and "properties" not in data + + +def _parse_node_ref(data: Any) -> GraphNodeRef: + if isinstance(data, GraphNodeRef): + return data + if not isinstance(data, dict): + raise ValueError("node reference must be a mapping or GraphNodeRef") + labels = data.get("labels", []) + if not isinstance(labels, list): + labels = [str(labels)] + properties = data.get("properties", {}) + if not isinstance(properties, dict): + properties = {} + node_id = data.get("id") + return GraphNodeRef( + id=str(node_id) if node_id is not None else None, + labels=labels, + properties=properties, + ) + + +def _parse_relationship_ref(data: Any) -> GraphRelationshipRef: + if isinstance(data, GraphRelationshipRef): + return data + if not isinstance(data, dict): + raise ValueError( + "relationship reference must be a mapping or GraphRelationshipRef" + ) + rel_type = data.get("type") + if not rel_type: + raise ValueError("relationship reference requires a type") + start_id = data.get("start_id") + end_id = data.get("end_id") + return GraphRelationshipRef( + type=str(rel_type), + start_id=str(start_id) if start_id is not None else None, + end_id=str(end_id) if end_id is not None else None, + ) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py new file mode 100644 index 000000000..b9db51872 --- /dev/null +++ b/tests/unit/test_explain.py @@ -0,0 +1,158 @@ +# 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 neo4j_graphrag.generation.explain import ( + build_explain_result, + graph_from_retriever, + sources_from_retriever, + trace_from_retriever, +) +from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem + + +def test_sources_from_retriever_maps_items() -> None: + result = RetrieverResult( + items=[ + RetrieverResultItem( + content="Movie: Avatar", + metadata={ + "score": 0.89, + "id": "4:abc", + "nodeLabels": ["Movie"], + "custom": "keep", + }, + ), + RetrieverResultItem(content="plain chunk"), + ] + ) + + sources = sources_from_retriever(result) + + assert len(sources) == 2 + assert sources[0].index == 1 + assert sources[0].content == "Movie: Avatar" + assert sources[0].score == 0.89 + assert sources[0].node_id == "4:abc" + assert sources[0].labels == ["Movie"] + assert sources[0].metadata == {"custom": "keep"} + assert sources[1].index == 2 + assert sources[1].score is None + + +def test_trace_from_retriever_uses_retriever_metadata() -> None: + result = RetrieverResult( + items=[], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + + trace = trace_from_retriever(result) + + assert trace.retriever == "VectorCypherRetriever" + + +def test_trace_from_retriever_defaults_to_unknown() -> None: + trace = trace_from_retriever(RetrieverResult(items=[])) + + assert trace.retriever == "unknown" + + +def test_graph_from_retriever_parses_graph_and_paths() -> None: + result = RetrieverResult( + items=[ + RetrieverResultItem( + content="Movie: Avatar", + metadata={ + "graph": { + "seed_node": { + "id": "4:movie", + "labels": ["Movie"], + "properties": {"title": "Avatar"}, + }, + "related_nodes": [ + { + "labels": ["Actor"], + "properties": {"name": "Zoe Saldana"}, + } + ], + "relationships": [ + { + "type": "ACTED_IN", + "start_id": "4:actor", + "end_id": "4:movie", + } + ], + "paths": [ + [ + { + "labels": ["Actor"], + "properties": {"name": "Zoe Saldana"}, + }, + {"type": "ACTED_IN"}, + { + "labels": ["Movie"], + "properties": {"title": "Avatar"}, + }, + ] + ], + } + }, + ) + ] + ) + + graph = graph_from_retriever(result) + + assert graph is not None + assert len(graph) == 1 + assert graph[0].seed_node is not None + assert graph[0].seed_node.properties["title"] == "Avatar" + assert graph[0].related_nodes[0].properties["name"] == "Zoe Saldana" + assert graph[0].relationships[0].type == "ACTED_IN" + assert len(graph[0].paths) == 1 + assert graph[0].paths[0][1].type == "ACTED_IN" + + +def test_graph_from_retriever_returns_none_without_graph_metadata() -> None: + result = RetrieverResult( + items=[RetrieverResultItem(content="chunk", metadata={"score": 0.5})] + ) + + assert graph_from_retriever(result) is None + + +def test_build_explain_result_combines_sources_trace_and_graph() -> None: + result = RetrieverResult( + items=[ + RetrieverResultItem( + content="Movie: Avatar", + metadata={ + "score": 0.89, + "graph": { + "seed_node": { + "labels": ["Movie"], + "properties": {"title": "Avatar"}, + } + }, + }, + ) + ], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + + explain = build_explain_result(result) + + assert explain.trace.retriever == "VectorCypherRetriever" + assert len(explain.sources) == 1 + assert explain.graph is not None + assert explain.graph[0].seed_node is not None From 09962bf8f64ace68ac4f8bd57df41332d10135af Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 10:53:15 +0200 Subject: [PATCH 03/20] Wire explain into GraphRAG.search with optional citations --- src/neo4j_graphrag/generation/__init__.py | 2 + src/neo4j_graphrag/generation/explain.py | 22 +++++++++ src/neo4j_graphrag/generation/graphrag.py | 33 +++++++++++-- src/neo4j_graphrag/generation/types.py | 3 ++ tests/unit/test_explain.py | 56 +++++++++++++++++++++++ 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 6f6d6fcc5..5282281d8 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -8,6 +8,7 @@ SourceRef, TraceStep, build_explain_result, + format_retrieval_context, graph_from_retriever, sources_from_retriever, trace_from_retriever, @@ -29,6 +30,7 @@ "SourceRef", "TraceStep", "build_explain_result", + "format_retrieval_context", "graph_from_retriever", "sources_from_retriever", "trace_from_retriever", diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index f9b199bd6..555419250 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -81,6 +81,12 @@ class ExplainResult(BaseModel): graph: list[GraphContext] | None = None +CITATION_SYSTEM_INSTRUCTIONS = ( + "Answer the user question using only the provided context. " + "Cite sources inline using [1], [2], etc. matching the context numbering." +) + + _SOURCE_METADATA_KEYS = frozenset( {"score", "id", "node_id", "element_id", "nodeLabels", "labels", "graph", "paths"} ) @@ -153,6 +159,22 @@ def build_explain_result(retriever_result: RetrieverResult) -> ExplainResult: ) +def format_retrieval_context( + retriever_result: RetrieverResult, + cite_sources: bool, +) -> str: + from neo4j_graphrag.types import RetrieverResult as RetrieverResultType + + if not isinstance(retriever_result, RetrieverResultType): + raise TypeError("retriever_result must be a RetrieverResult") + if not cite_sources: + return "\n".join(str(item.content) for item in retriever_result.items) + return "\n".join( + f"[{index}] {item.content}" + for index, item in enumerate(retriever_result.items, start=1) + ) + + def _coerce_float(value: Any) -> float | None: if value is None: return None diff --git a/src/neo4j_graphrag/generation/graphrag.py b/src/neo4j_graphrag/generation/graphrag.py index 49f3dd3d5..c97e713f8 100644 --- a/src/neo4j_graphrag/generation/graphrag.py +++ b/src/neo4j_graphrag/generation/graphrag.py @@ -27,6 +27,12 @@ RagInitializationError, SearchValidationError, ) +from neo4j_graphrag.generation.explain import ( + ExplainConfig, + build_explain_result, + format_retrieval_context, + CITATION_SYSTEM_INSTRUCTIONS, +) from neo4j_graphrag.generation.prompts import RagTemplate from neo4j_graphrag.generation.types import RagInitModel, RagResultModel, RagSearchModel from neo4j_graphrag.llm import LLMInterface, LLMInterfaceV2 @@ -98,6 +104,7 @@ def search( retriever_config: Optional[dict[str, Any]] = None, return_context: Optional[bool] = None, response_fallback: Optional[str] = None, + explain: Optional[ExplainConfig] = None, ) -> RagResultModel: """ .. warning:: @@ -122,12 +129,16 @@ def search( (default: False). response_fallback (Optional[str]): If not null, will return this message instead of calling the LLM if context comes back empty. + explain (Optional[ExplainConfig]): When set, attach structured provenance to the + result and include retrieval context in the response. Returns: RagResultModel: The LLM-generated answer. """ - if return_context is None: + if explain is not None: + return_context = True + elif return_context is None: warnings.warn( "The default value of 'return_context' will change from 'False'" " to 'True' in a future version.", @@ -142,6 +153,7 @@ def search( retriever_config=retriever_config or {}, return_context=return_context, response_fallback=response_fallback, + explain=explain, ) except ValidationError as e: raise SearchValidationError(e.errors()) @@ -154,7 +166,14 @@ def search( if len(retriever_result.items) == 0 and response_fallback is not None: answer = response_fallback else: - context = "\n".join(item.content for item in retriever_result.items) + cite_sources = ( + validated_data.explain.cite_sources + if validated_data.explain is not None + else False + ) + context = format_retrieval_context( + retriever_result, cite_sources=cite_sources + ) prompt = self.prompt_template.format( query_text=query_text, context=context, examples=validated_data.examples ) @@ -162,12 +181,16 @@ def search( logger.debug("RAG: retriever_result=%s", prettify(retriever_result)) logger.debug("RAG: prompt=%s", prompt) + system_instruction = self.prompt_template.system_instructions + if cite_sources: + system_instruction = CITATION_SYSTEM_INSTRUCTIONS + if self.is_langchain_compatible(): # llm interface v2 or langchain chat model messages = legacy_inputs_to_messages( prompt=prompt, message_history=message_history, - system_instruction=self.prompt_template.system_instructions, + system_instruction=system_instruction, ) # langchain chat model compatible invoke @@ -179,7 +202,7 @@ def search( llm_response = self.llm.invoke( input=prompt, message_history=message_history, - system_instruction=self.prompt_template.system_instructions, + system_instruction=system_instruction, ) else: raise ValueError(f"Type {type(self.llm)} of LLM is not supported.") @@ -187,6 +210,8 @@ def search( result: dict[str, Any] = {"answer": answer} if return_context: result["retriever_result"] = retriever_result + if validated_data.explain is not None: + result["explain"] = build_explain_result(retriever_result) return RagResultModel(**result) def _build_query( diff --git a/src/neo4j_graphrag/generation/types.py b/src/neo4j_graphrag/generation/types.py index 8569cb9e8..9a4dc1654 100644 --- a/src/neo4j_graphrag/generation/types.py +++ b/src/neo4j_graphrag/generation/types.py @@ -18,6 +18,7 @@ from pydantic import BaseModel, ConfigDict, field_validator +from neo4j_graphrag.generation.explain import ExplainConfig, ExplainResult from neo4j_graphrag.generation.prompts import RagTemplate from neo4j_graphrag.retrievers.base import Retriever from neo4j_graphrag.types import RetrieverResult @@ -44,10 +45,12 @@ class RagSearchModel(BaseModel): retriever_config: dict[str, Any] = {} return_context: bool = False response_fallback: Optional[str] = None + explain: Optional[ExplainConfig] = None class RagResultModel(BaseModel): answer: str retriever_result: Optional[RetrieverResult] = None + explain: Optional[ExplainResult] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index b9db51872..d5429a233 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -12,12 +12,18 @@ # 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 + from neo4j_graphrag.generation.explain import ( + ExplainConfig, build_explain_result, + format_retrieval_context, graph_from_retriever, sources_from_retriever, trace_from_retriever, ) +from neo4j_graphrag.generation.graphrag import GraphRAG +from neo4j_graphrag.llm import LLMResponse from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem @@ -156,3 +162,53 @@ def test_build_explain_result_combines_sources_trace_and_graph() -> None: assert len(explain.sources) == 1 assert explain.graph is not None assert explain.graph[0].seed_node is not None + + +def test_format_retrieval_context_adds_source_indexes() -> None: + result = RetrieverResult( + items=[ + RetrieverResultItem(content="first"), + RetrieverResultItem(content="second"), + ] + ) + + context = format_retrieval_context(result, cite_sources=True) + + assert context == "[1] first\n[2] second" + + +def test_graphrag_search_attaches_explain( + retriever_mock: MagicMock, llm: MagicMock +) -> None: + rag = GraphRAG(retriever=retriever_mock, llm=llm) + retriever_mock.search.return_value = RetrieverResult( + items=[RetrieverResultItem(content="Movie: Avatar", metadata={"score": 0.9})], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + llm.invoke.return_value = LLMResponse(content="answer [1]") + + result = rag.search("Who acted in Avatar?", explain=ExplainConfig()) + + assert result.explain is not None + assert result.explain.trace.retriever == "VectorCypherRetriever" + assert result.retriever_result is not None + llm.invoke.assert_called_once() + assert "[1] Movie: Avatar" in llm.invoke.call_args.kwargs["input"] + assert "Cite sources inline" in llm.invoke.call_args.kwargs["system_instruction"] + + +def test_graphrag_search_without_explain_unchanged( + retriever_mock: MagicMock, llm: MagicMock +) -> None: + rag = GraphRAG(retriever=retriever_mock, llm=llm) + retriever_mock.search.return_value = RetrieverResult( + items=[RetrieverResultItem(content="chunk")] + ) + llm.invoke.return_value = LLMResponse(content="answer") + + result = rag.search("question") + + assert result.explain is None + assert result.retriever_result is None + assert "chunk" in llm.invoke.call_args.kwargs["input"] + assert "[1]" not in llm.invoke.call_args.kwargs["input"] From 27cec2f549139c6b0e7083b8b372a0c211dad027 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:04:45 +0200 Subject: [PATCH 04/20] Add path serialization and movies explain formatter for VectorCypher --- src/neo4j_graphrag/generation/__init__.py | 12 ++ src/neo4j_graphrag/generation/explain.py | 163 +++++++++++++++++++++- tests/unit/test_explain.py | 125 +++++++++++++++++ 3 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 5282281d8..8b0481700 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -5,13 +5,19 @@ GraphNodeRef, GraphPath, GraphRelationshipRef, + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, SourceRef, TraceStep, build_explain_result, format_retrieval_context, + graph_and_paths_from_record, graph_from_retriever, + movies_vector_cypher_explain_formatter, + serialize_neo4j_path, + serialize_paths, sources_from_retriever, trace_from_retriever, + vector_cypher_explain_result_formatter, ) from .graphrag import GraphRAG from .prompts import PromptTemplate, RagTemplate, SchemaExtractionTemplate @@ -24,6 +30,7 @@ "GraphPath", "GraphRelationshipRef", "GraphRAG", + "MOVIES_ACTORS_PATH_RETRIEVAL_QUERY", "PromptTemplate", "RagTemplate", "SchemaExtractionTemplate", @@ -31,7 +38,12 @@ "TraceStep", "build_explain_result", "format_retrieval_context", + "graph_and_paths_from_record", "graph_from_retriever", + "movies_vector_cypher_explain_formatter", + "serialize_neo4j_path", + "serialize_paths", "sources_from_retriever", "trace_from_retriever", + "vector_cypher_explain_result_formatter", ] diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 555419250..8e879217c 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -14,12 +14,22 @@ # limitations under the License. from __future__ import annotations -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Callable, Union +import neo4j from pydantic import BaseModel, Field, PositiveInt if TYPE_CHECKING: - from neo4j_graphrag.types import RetrieverResult + from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem + +MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ +MATCH path = (actor:Actor)-[:ACTED_IN]->(node) +RETURN node.title AS movieTitle, + node.plot AS moviePlot, + collect(DISTINCT actor.name) AS actors, + collect(path) AS paths, + score AS similarityScore +""".strip() GraphPathElement = Union["GraphNodeRef", "GraphRelationshipRef"] GraphPath = list[GraphPathElement] @@ -290,3 +300,152 @@ def _parse_relationship_ref(data: Any) -> GraphRelationshipRef: start_id=str(start_id) if start_id is not None else None, end_id=str(end_id) if end_id is not None else None, ) + + +def _node_from_neo4j_graph_node(node: neo4j.graph.Node) -> GraphNodeRef: + labels = list(node.labels) + properties = dict(node.items()) + return GraphNodeRef( + id=node.element_id, + labels=labels, + properties=properties, + ) + + +def _relationship_from_neo4j_graph_rel( + relationship: neo4j.graph.Relationship, +) -> GraphRelationshipRef: + return GraphRelationshipRef( + type=relationship.type, + start_id=relationship.start_node.element_id, + end_id=relationship.end_node.element_id, + ) + + +def serialize_neo4j_path(path: neo4j.graph.Path) -> GraphPath: + elements: GraphPath = [] + nodes = list(path.nodes) + relationships = list(path.relationships) + for index, node in enumerate(nodes): + elements.append(_node_from_neo4j_graph_node(node)) + if index < len(relationships): + elements.append(_relationship_from_neo4j_graph_rel(relationships[index])) + return elements + + +def serialize_paths(paths_value: Any) -> list[GraphPath]: + if not paths_value or not isinstance(paths_value, list): + return [] + paths: list[GraphPath] = [] + for path in paths_value: + if isinstance(path, neo4j.graph.Path): + paths.append(serialize_neo4j_path(path)) + continue + if isinstance(path, list): + parsed = _parse_paths([path]) + if parsed: + paths.append(parsed[0]) + return paths + + +def graph_and_paths_from_record( + record: neo4j.Record, + *, + node_key: str = "node", + title_key: str = "movieTitle", + plot_key: str = "moviePlot", + actors_key: str = "actors", + paths_key: str = "paths", + movie_labels: list[str] | None = None, + actor_labels: list[str] | None = None, +) -> dict[str, Any]: + """Build a metadata.graph mapping from a VectorCypher retrieval row.""" + movie_label_list = movie_labels or ["Movie"] + actor_label_list = actor_labels or ["Actor"] + + seed_node: GraphNodeRef | None = None + node = record.get(node_key) + if isinstance(node, neo4j.graph.Node): + seed_node = _node_from_neo4j_graph_node(node) + else: + title = record.get(title_key) + plot = record.get(plot_key) + if title is not None or plot is not None: + properties: dict[str, Any] = {} + if title is not None: + properties["title"] = title + if plot is not None: + properties["plot"] = plot + seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) + + related_nodes: list[GraphNodeRef] = [] + relationships: list[GraphRelationshipRef] = [] + actors = record.get(actors_key) or [] + if isinstance(actors, list): + for actor_name in actors: + if actor_name is None: + continue + actor_node = GraphNodeRef( + labels=actor_label_list, + properties={"name": str(actor_name)}, + ) + related_nodes.append(actor_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="ACTED_IN", + start_id=None, + end_id=seed_node.id, + ) + ) + + paths = serialize_paths(record.get(paths_key)) + if not paths and seed_node is not None and related_nodes: + for actor_node in related_nodes: + paths.append( + [ + actor_node, + GraphRelationshipRef(type="ACTED_IN"), + seed_node, + ] + ) + + return GraphContext( + seed_node=seed_node, + related_nodes=related_nodes, + relationships=relationships, + paths=paths, + ).model_dump(exclude_none=True) + + +def vector_cypher_explain_result_formatter( + record: neo4j.Record, + *, + content: str, + score_key: str = "similarityScore", + graph_builder: Callable[[neo4j.Record], dict[str, Any]] | None = None, +) -> RetrieverResultItem: + from neo4j_graphrag.types import RetrieverResultItem + + graph = (graph_builder or graph_and_paths_from_record)(record) + return RetrieverResultItem( + content=content, + metadata={ + "score": record.get(score_key), + "graph": graph, + }, + ) + + +def movies_vector_cypher_explain_formatter( + record: neo4j.Record, +) -> RetrieverResultItem: + actors = record.get("actors") or [] + if isinstance(actors, list): + actors_text = ", ".join(str(actor) for actor in actors if actor is not None) + else: + actors_text = str(actors) + title = record.get("movieTitle") + plot = record.get("moviePlot") + content = f"Movie title: {title}, Plot: {plot}, Actors: {actors_text}" + return vector_cypher_explain_result_formatter(record, content=content) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index d5429a233..75cb5e1c6 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -14,11 +14,15 @@ # limitations under the License. from unittest.mock import MagicMock +import neo4j from neo4j_graphrag.generation.explain import ( ExplainConfig, build_explain_result, format_retrieval_context, + graph_and_paths_from_record, graph_from_retriever, + movies_vector_cypher_explain_formatter, + serialize_neo4j_path, sources_from_retriever, trace_from_retriever, ) @@ -212,3 +216,124 @@ def test_graphrag_search_without_explain_unchanged( assert result.retriever_result is None assert "chunk" in llm.invoke.call_args.kwargs["input"] assert "[1]" not in llm.invoke.call_args.kwargs["input"] + + +def test_graph_and_paths_from_record_builds_seed_and_actors() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana", "Sam Worthington"], + "paths": None, + } + ) + + graph = graph_and_paths_from_record(record) + + assert graph["seed_node"]["properties"]["title"] == "Avatar" + assert len(graph["related_nodes"]) == 2 + assert graph["related_nodes"][0]["properties"]["name"] == "Zoe Saldana" + assert len(graph["paths"]) == 2 + assert graph["paths"][0][1]["type"] == "ACTED_IN" + + +def test_serialize_neo4j_path_from_graph_objects() -> None: + actor = MagicMock(spec=neo4j.graph.Node) + actor.element_id = "4:actor" + actor.labels = ["Actor"] + actor.items.return_value = {"name": "Zoe Saldana"}.items() + movie = MagicMock(spec=neo4j.graph.Node) + movie.element_id = "4:movie" + movie.labels = ["Movie"] + movie.items.return_value = {"title": "Avatar"}.items() + relationship = MagicMock(spec=neo4j.graph.Relationship) + relationship.type = "ACTED_IN" + relationship.start_node = actor + relationship.end_node = movie + path = MagicMock(spec=neo4j.graph.Path) + path.nodes = [actor, movie] + path.relationships = [relationship] + + serialized = serialize_neo4j_path(path) + + assert serialized[0].id == "4:actor" + assert serialized[0].properties["name"] == "Zoe Saldana" + assert serialized[1].type == "ACTED_IN" + assert serialized[1].start_id == "4:actor" + assert serialized[1].end_id == "4:movie" + assert serialized[2].properties["title"] == "Avatar" + + +def test_graph_and_paths_from_record_uses_neo4j_paths() -> None: + actor = MagicMock(spec=neo4j.graph.Node) + actor.element_id = "4:actor" + actor.labels = ["Actor"] + actor.items.return_value = {"name": "Zoe Saldana"}.items() + movie = MagicMock(spec=neo4j.graph.Node) + movie.element_id = "4:movie" + movie.labels = ["Movie"] + movie.items.return_value = {"title": "Avatar"}.items() + relationship = MagicMock(spec=neo4j.graph.Relationship) + relationship.type = "ACTED_IN" + relationship.start_node = actor + relationship.end_node = movie + path = MagicMock(spec=neo4j.graph.Path) + path.nodes = [actor, movie] + path.relationships = [relationship] + record = neo4j.Record( + { + "node": movie, + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "paths": [path], + } + ) + + graph = graph_and_paths_from_record(record) + + assert graph["seed_node"]["id"] == "4:movie" + assert len(graph["paths"]) == 1 + assert graph["paths"][0][0]["id"] == "4:actor" + assert graph["paths"][0][1]["start_id"] == "4:actor" + + +def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "similarityScore": 0.91, + "paths": None, + } + ) + + item = movies_vector_cypher_explain_formatter(record) + + assert "Avatar" in item.content + assert item.metadata is not None + assert item.metadata["score"] == 0.91 + assert item.metadata["graph"]["seed_node"]["properties"]["title"] == "Avatar" + + +def test_build_explain_result_from_movies_formatter_output() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "similarityScore": 0.91, + "paths": None, + } + ) + result = RetrieverResult( + items=[movies_vector_cypher_explain_formatter(record)], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + + explain = build_explain_result(result) + + assert explain.graph is not None + assert explain.graph[0].paths[0][1].type == "ACTED_IN" + assert explain.sources[0].score == 0.91 From 6e6294970561031a7548a037dd3300ee7a44e392 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:06:49 +0200 Subject: [PATCH 05/20] Add GraphRAG explainability demo script and documentation --- docs/source/api.rst | 11 ++ docs/source/user_guide_rag.rst | 43 +++++ examples/README.md | 1 + .../graphrag_with_explain.py | 157 ++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 examples/question_answering/graphrag_with_explain.py diff --git a/docs/source/api.rst b/docs/source/api.rst index 069d21ffe..0978dc50f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -470,6 +470,17 @@ GraphRAG .. autoclass:: neo4j_graphrag.generation.graphrag.GraphRAG :members: +Explainability +============== + +.. autoclass:: neo4j_graphrag.generation.explain.ExplainConfig + +.. autoclass:: neo4j_graphrag.generation.explain.ExplainResult + +.. autoclass:: neo4j_graphrag.generation.explain.SourceRef + +.. autoclass:: neo4j_graphrag.generation.explain.GraphContext + .. _database-interaction-section: diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 2186fe10d..e0f9a9a52 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1452,6 +1452,49 @@ parameter for the retriever: ) +Explainability +============== + +GraphRAG can attach structured provenance to a search result when ``explain`` is set. +The payload includes numbered sources (aligned with LLM context), optional graph +neighborhood and paths from the retriever, and a minimal retriever trace. + +Use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each +retrieved item. The package provides helpers such as +``movies_vector_cypher_explain_formatter`` and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY`` +for the public recommendations demo database. + +.. code:: python + + from neo4j_graphrag.generation import ( + ExplainConfig, + GraphRAG, + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + movies_vector_cypher_explain_formatter, + ) + from neo4j_graphrag.retrievers import VectorCypherRetriever + + retriever = VectorCypherRetriever( + driver, + index_name="moviePlotsEmbedding", + retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + result_formatter=movies_vector_cypher_explain_formatter, + embedder=embedder, + ) + rag = GraphRAG(retriever=retriever, llm=llm) + result = rag.search( + "Who were the actors in Avatar?", + explain=ExplainConfig(), + ) + print(result.answer) + print(result.explain) + +When ``explain`` is set, ``return_context`` is enabled automatically and sources +are numbered in the LLM prompt so answers can cite ``[1]``, ``[2]``, and so on. + +See also: ``examples/question_answering/graphrag_with_explain.py``. + + ************** DB Operations ************** diff --git a/examples/README.md b/examples/README.md index dcbc44021..cbb9fb2e2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -55,6 +55,7 @@ are listed in [the last section of this file](#customize). - [End to end GraphRAG](./answer/graphrag.py) - [GraphRAG with message history](./question_answering/graphrag_with_message_history.py) - [GraphRAG with Neo4j message history](./question_answering/graphrag_with_neo4j_message_history.py) +- [GraphRAG with explainability](./question_answering/graphrag_with_explain.py) ## Customize diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py new file mode 100644 index 000000000..1cc14a865 --- /dev/null +++ b/examples/question_answering/graphrag_with_explain.py @@ -0,0 +1,157 @@ +"""GraphRAG with optional explainability output. + +Runs against the public Neo4j recommendations demo database and compares a +standard GraphRAG answer with structured provenance (sources, graph paths). + +Requires OPENAI_API_KEY in the environment. + +Examples: + + uv run examples/question_answering/graphrag_with_explain.py + uv run examples/question_answering/graphrag_with_explain.py --no-explain + uv run examples/question_answering/graphrag_with_explain.py --format json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +import neo4j +from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings +from neo4j_graphrag.generation import ( + ExplainConfig, + ExplainResult, + GraphRAG, + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + movies_vector_cypher_explain_formatter, +) +from neo4j_graphrag.llm import OpenAILLM +from neo4j_graphrag.retrievers import VectorCypherRetriever + +URI = "neo4j+s://demo.neo4jlabs.com" +AUTH = ("recommendations", "recommendations") +DATABASE = "recommendations" +INDEX = "moviePlotsEmbedding" +DEFAULT_QUESTION = "Who were the actors in Avatar?" + + +def build_rag(driver: neo4j.Driver) -> GraphRAG: + retriever = VectorCypherRetriever( + driver, + index_name=INDEX, + retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + result_formatter=movies_vector_cypher_explain_formatter, + embedder=OpenAIEmbeddings(), + neo4j_database=DATABASE, + ) + llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0}) + return GraphRAG(retriever=retriever, llm=llm) + + +def _node_label(node: dict[str, Any]) -> str: + labels = node.get("labels") or [] + name = (node.get("properties") or {}).get("name") or ( + node.get("properties") or {} + ).get("title") + if labels and name: + return f"{labels[0]}({name})" + if labels: + return labels[0] + if name: + return str(name) + return "node" + + +def _format_path(path: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for element in path: + if "type" in element: + parts.append(f"-[:{element['type']}]->") + else: + parts.append(_node_label(element)) + return " ".join(parts) + + +def format_explain_table(explain: ExplainResult) -> str: + lines = [f"Retriever: {explain.trace.retriever}", "", "Sources:"] + for source in explain.sources: + score = f" (score={source.score:.3f})" if source.score is not None else "" + lines.append(f" [{source.index}]{score} {source.content}") + + if explain.graph: + lines.extend(["", "Graph context:"]) + for index, context in enumerate(explain.graph, start=1): + lines.append(f" Source {index}:") + if context.seed_node is not None: + lines.append(f" seed: {_node_label(context.seed_node.model_dump())}") + for path_index, path in enumerate(context.paths, start=1): + serialized_path = [element.model_dump() for element in path] + lines.append(f" path {path_index}: {_format_path(serialized_path)}") + return "\n".join(lines) + + +def result_to_json(answer: str, explain: ExplainResult | None) -> dict[str, Any]: + payload: dict[str, Any] = {"answer": answer} + if explain is not None: + payload["explain"] = explain.model_dump() + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "question", + nargs="?", + default=DEFAULT_QUESTION, + help=f"Question to ask (default: {DEFAULT_QUESTION!r})", + ) + parser.add_argument( + "--explain", + action=argparse.BooleanOptionalAction, + default=True, + help="Attach structured provenance to the GraphRAG result (default: on)", + ) + parser.add_argument( + "--format", + choices=("table", "json"), + default="table", + help="Output format (default: table)", + ) + parser.add_argument( + "--top-k", + type=int, + default=3, + help="Number of retrieval results to pass to the LLM (default: 3)", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + with neo4j.GraphDatabase.driver(URI, auth=AUTH) as driver: + rag = build_rag(driver) + result = rag.search( + args.question, + explain=ExplainConfig() if args.explain else None, + retriever_config={"top_k": args.top_k}, + ) + + if args.format == "json": + print(json.dumps(result_to_json(result.answer, result.explain), indent=2)) + return 0 + + print(f"Question: {args.question}") + print() + print("Answer:") + print(result.answer) + if result.explain is not None: + print() + print(format_explain_table(result.explain)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e0313dad48160d7b53e5b476b5d7c99c713f2254 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:26:38 +0200 Subject: [PATCH 06/20] Fixes to query, formatter, json --- docs/source/user_guide_rag.rst | 8 +- .../graphrag_with_explain.py | 20 ++++- pyproject.toml | 1 + src/neo4j_graphrag/generation/explain.py | 79 +++++++++++++++++-- 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_explain.py | 56 +++++++++++++ tests/unit/test_neo4j_queries.py | 7 +- tests/unit/utils/test_version_utils.py | 24 ++++++ uv.lock | 2 + 12 files changed, 255 insertions(+), 27 deletions(-) diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index e0f9a9a52..3084ebcb7 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1462,7 +1462,8 @@ neighborhood and paths from the retriever, and a minimal retriever trace. Use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each retrieved item. The package provides helpers such as ``movies_vector_cypher_explain_formatter`` and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY`` -for the public recommendations demo database. +for the public recommendations demo database. The query retrieves actors, +directors, and graph paths for each matched movie. .. code:: python @@ -1494,6 +1495,11 @@ are numbered in the LLM prompt so answers can cite ``[1]``, ``[2]``, and so on. See also: ``examples/question_answering/graphrag_with_explain.py``. +This example uses OpenAI for embeddings and generation. Install the optional +dependency before running it:: + + uv sync --extra openai + ************** DB Operations diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 1cc14a865..af9176dc2 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -3,9 +3,11 @@ Runs against the public Neo4j recommendations demo database and compares a standard GraphRAG answer with structured provenance (sources, graph paths). -Requires OPENAI_API_KEY in the environment. +Requires OPENAI_API_KEY in the environment and the ``openai`` optional dependency:: -Examples: + uv sync --extra openai + +Examples:: uv run examples/question_answering/graphrag_with_explain.py uv run examples/question_answering/graphrag_with_explain.py --no-explain @@ -36,6 +38,17 @@ DATABASE = "recommendations" INDEX = "moviePlotsEmbedding" DEFAULT_QUESTION = "Who were the actors in Avatar?" +OPENAI_EXTRA_INSTALL_HINT = ( + "This example requires the openai optional dependency.\n" + "Install it with: uv sync --extra openai" +) + + +def ensure_openai_extra() -> None: + try: + import openai # noqa: F401 + except ImportError as exc: + raise SystemExit(OPENAI_EXTRA_INSTALL_HINT) from exc def build_rag(driver: neo4j.Driver) -> GraphRAG: @@ -96,7 +109,7 @@ def format_explain_table(explain: ExplainResult) -> str: def result_to_json(answer: str, explain: ExplainResult | None) -> dict[str, Any]: payload: dict[str, Any] = {"answer": answer} if explain is not None: - payload["explain"] = explain.model_dump() + payload["explain"] = explain.model_dump(mode="json") return payload @@ -131,6 +144,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + ensure_openai_extra() with neo4j.GraphDatabase.driver(URI, auth=AUTH) as driver: rag = build_rag(driver) result = rag.search( diff --git a/pyproject.toml b/pyproject.toml index ab3f47369..0c44ff8d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ experimental = [ "pyarrow>=20.0.0", # ParquetWriter, Neo4jGraphParquetFormatter ] examples = [ + "openai>=1.51.1,<2.0.0", "langchain-openai>=0.2.2,<2.0.0", "langchain-huggingface>=0.1.0,<2.0.0", ] diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 8e879217c..30b2c7da5 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -24,10 +24,13 @@ MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ MATCH path = (actor:Actor)-[:ACTED_IN]->(node) +WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths +OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) RETURN node.title AS movieTitle, node.plot AS moviePlot, - collect(DISTINCT actor.name) AS actors, - collect(path) AS paths, + actors, + [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, + actorPaths + [path IN collect(directorPath) WHERE path IS NOT NULL] AS paths, score AS similarityScore """.strip() @@ -302,9 +305,24 @@ def _parse_relationship_ref(data: Any) -> GraphRelationshipRef: ) +def _json_safe_value(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe_value(item) for item in value] + if value is None or isinstance(value, (str, int, float, bool)): + return value + iso_format = getattr(value, "iso_format", None) + if callable(iso_format): + return iso_format() + return str(value) + + def _node_from_neo4j_graph_node(node: neo4j.graph.Node) -> GraphNodeRef: labels = list(node.labels) - properties = dict(node.items()) + properties = _json_safe_value(dict(node.items())) + if not isinstance(properties, dict): + properties = {} return GraphNodeRef( id=node.element_id, labels=labels, @@ -355,13 +373,16 @@ def graph_and_paths_from_record( title_key: str = "movieTitle", plot_key: str = "moviePlot", actors_key: str = "actors", + directors_key: str = "directors", paths_key: str = "paths", movie_labels: list[str] | None = None, actor_labels: list[str] | None = None, + director_labels: list[str] | None = None, ) -> dict[str, Any]: """Build a metadata.graph mapping from a VectorCypher retrieval row.""" movie_label_list = movie_labels or ["Movie"] actor_label_list = actor_labels or ["Actor"] + director_label_list = director_labels or ["Person"] seed_node: GraphNodeRef | None = None node = record.get(node_key) @@ -399,13 +420,47 @@ def graph_and_paths_from_record( ) ) + directors = record.get(directors_key) or [] + if isinstance(directors, list): + for director_name in directors: + if director_name is None: + continue + director_node = GraphNodeRef( + labels=director_label_list, + properties={"name": str(director_name)}, + ) + related_nodes.append(director_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="DIRECTED", + start_id=None, + end_id=seed_node.id, + ) + ) + paths = serialize_paths(record.get(paths_key)) if not paths and seed_node is not None and related_nodes: - for actor_node in related_nodes: + for person_name, rel_type, labels in ( + *( + (name, "ACTED_IN", actor_label_list) + for name in actors + if isinstance(actors, list) and name is not None + ), + *( + (name, "DIRECTED", director_label_list) + for name in directors + if isinstance(directors, list) and name is not None + ), + ): + person_node = GraphNodeRef( + labels=labels, + properties={"name": str(person_name)}, + ) paths.append( [ - actor_node, - GraphRelationshipRef(type="ACTED_IN"), + person_node, + GraphRelationshipRef(type=rel_type), seed_node, ] ) @@ -445,7 +500,17 @@ def movies_vector_cypher_explain_formatter( actors_text = ", ".join(str(actor) for actor in actors if actor is not None) else: actors_text = str(actors) + directors = record.get("directors") or [] + if isinstance(directors, list): + directors_text = ", ".join( + str(director) for director in directors if director is not None + ) + else: + directors_text = str(directors) title = record.get("movieTitle") plot = record.get("moviePlot") - content = f"Movie title: {title}, Plot: {plot}, Actors: {actors_text}" + content = ( + f"Movie title: {title}, Plot: {plot}, " + f"Actors: {actors_text}, Directors: {directors_text}" + ) return vector_cypher_explain_result_formatter(record, content=content) 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_explain.py b/tests/unit/test_explain.py index 75cb5e1c6..5f4ebc2b0 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -17,6 +17,10 @@ import neo4j from neo4j_graphrag.generation.explain import ( ExplainConfig, + ExplainResult, + GraphContext, + GraphRelationshipRef, + TraceStep, build_explain_result, format_retrieval_context, graph_and_paths_from_record, @@ -218,6 +222,28 @@ def test_graphrag_search_without_explain_unchanged( assert "[1]" not in llm.invoke.call_args.kwargs["input"] +def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: + record = neo4j.Record( + { + "movieTitle": "One Flew Over the Cuckoo's Nest", + "moviePlot": "A criminal pleads insanity.", + "actors": ["Michael Berryman"], + "directors": ["Milos Forman"], + "paths": None, + } + ) + + graph = graph_and_paths_from_record(record) + + assert ( + graph["seed_node"]["properties"]["title"] == "One Flew Over the Cuckoo's Nest" + ) + assert len(graph["related_nodes"]) == 2 + assert len(graph["paths"]) == 2 + assert graph["paths"][0][1]["type"] == "ACTED_IN" + assert graph["paths"][1][1]["type"] == "DIRECTED" + + def test_graph_and_paths_from_record_builds_seed_and_actors() -> None: record = neo4j.Record( { @@ -264,6 +290,34 @@ def test_serialize_neo4j_path_from_graph_objects() -> None: assert serialized[2].properties["title"] == "Avatar" +def test_node_from_neo4j_graph_node_serializes_temporal_properties() -> None: + import neo4j.time + + movie = MagicMock(spec=neo4j.graph.Node) + movie.element_id = "4:movie" + movie.labels = ["Movie"] + movie.items.return_value = { + "title": "One Flew Over the Cuckoo's Nest", + "released": neo4j.time.Date(1975, 11, 19), + }.items() + + from neo4j_graphrag.generation.explain import _node_from_neo4j_graph_node + + node_ref = _node_from_neo4j_graph_node(movie) + + assert node_ref.properties["released"] == "1975-11-19" + assert ExplainResult( + sources=[], + trace=TraceStep(retriever="VectorCypherRetriever"), + graph=[ + GraphContext( + seed_node=node_ref, + paths=[[node_ref, GraphRelationshipRef(type="ACTED_IN"), node_ref]], + ) + ], + ).model_dump(mode="json") + + def test_graph_and_paths_from_record_uses_neo4j_paths() -> None: actor = MagicMock(spec=neo4j.graph.Node) actor.element_id = "4:actor" @@ -304,6 +358,7 @@ def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: "movieTitle": "Avatar", "moviePlot": "A marine on an alien planet.", "actors": ["Zoe Saldana"], + "directors": ["James Cameron"], "similarityScore": 0.91, "paths": None, } @@ -312,6 +367,7 @@ def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: item = movies_vector_cypher_explain_formatter(record) assert "Avatar" in item.content + assert "James Cameron" in item.content assert item.metadata is not None assert item.metadata["score"] == 0.91 assert item.metadata["graph"]["seed_node"]["properties"]["title"] == "Avatar" 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 + ) diff --git a/uv.lock b/uv.lock index e4e0a23f4..7bb7de04e 100644 --- a/uv.lock +++ b/uv.lock @@ -3004,6 +3004,7 @@ cohere = [ examples = [ { name = "langchain-huggingface" }, { name = "langchain-openai" }, + { name = "openai" }, ] experimental = [ { name = "langchain-text-splitters" }, @@ -3081,6 +3082,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.9' and python_full_version < '3.13'", specifier = ">=2.0.0,<3.0.0" }, { name = "numpy", marker = "python_full_version >= '3.13' and python_full_version < '3.15'", specifier = ">=2.1.0,<3.0.0" }, { name = "ollama", marker = "extra == 'ollama'", specifier = ">=0.4.4,<0.5.0" }, + { name = "openai", marker = "extra == 'examples'", specifier = ">=1.51.1,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.51.1,<2.0.0" }, { name = "pinecone-client", marker = "extra == 'pinecone'", specifier = ">=4.1.0,<5.0.0" }, { name = "pyarrow", marker = "extra == 'experimental'", specifier = ">=20.0.0" }, From 3809c26d0032d1234a92b3c4b48fa981df51263a Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:43:05 +0200 Subject: [PATCH 07/20] Generalize demo via text2cypher --- docs/source/user_guide_rag.rst | 38 +++++- .../graphrag_with_explain.py | 115 +++++++++--------- src/neo4j_graphrag/generation/__init__.py | 2 + src/neo4j_graphrag/generation/explain.py | 27 +++- src/neo4j_graphrag/generation/graphrag.py | 8 ++ tests/unit/test_explain.py | 47 +++++++ 6 files changed, 171 insertions(+), 66 deletions(-) diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 3084ebcb7..1a655efa5 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1459,11 +1459,39 @@ GraphRAG can attach structured provenance to a search result when ``explain`` is The payload includes numbered sources (aligned with LLM context), optional graph neighborhood and paths from the retriever, and a minimal retriever trace. -Use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each -retrieved item. The package provides helpers such as -``movies_vector_cypher_explain_formatter`` and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY`` -for the public recommendations demo database. The query retrieves actors, -directors, and graph paths for each matched movie. +For ask-anything graph questions, pair explain with ``Text2CypherRetriever`` and +``text2cypher_explain_result_formatter``. The generated Cypher query is available +on ``result.explain.trace.cypher``. + +.. code:: python + + from neo4j_graphrag.generation import ( + ExplainConfig, + GraphRAG, + text2cypher_explain_result_formatter, + ) + from neo4j_graphrag.retrievers import Text2CypherRetriever + + retriever = Text2CypherRetriever( + driver, + llm=llm, + neo4j_schema=schema, + examples=examples, + result_formatter=text2cypher_explain_result_formatter, + ) + rag = GraphRAG(retriever=retriever, llm=llm) + result = rag.search( + "Which movies did Joel Coen and Steve Buscemi work on together?", + explain=ExplainConfig(), + ) + print(result.answer) + print(result.explain) + +For graph-path provenance from vector similarity hits, use a VectorCypher +``result_formatter`` that populates ``metadata.graph`` on each retrieved item. +The package provides helpers such as ``movies_vector_cypher_explain_formatter`` +and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY`` for the public recommendations demo +database. .. code:: python diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index af9176dc2..f768e3429 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -1,7 +1,8 @@ """GraphRAG with optional explainability output. -Runs against the public Neo4j recommendations demo database and compares a -standard GraphRAG answer with structured provenance (sources, graph paths). +Uses Text2Cypher against the public Neo4j recommendations demo database so you +can ask natural-language questions about the graph and inspect sources plus the +generated Cypher query. Requires OPENAI_API_KEY in the environment and the ``openai`` optional dependency:: @@ -11,7 +12,8 @@ uv run examples/question_answering/graphrag_with_explain.py uv run examples/question_answering/graphrag_with_explain.py --no-explain - uv run examples/question_answering/graphrag_with_explain.py --format json + uv run examples/question_answering/graphrag_with_explain.py --format json \\ + "Which movies did Joel Coen and Steve Buscemi work on together?" """ from __future__ import annotations @@ -22,27 +24,60 @@ from typing import Any import neo4j -from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings from neo4j_graphrag.generation import ( ExplainConfig, ExplainResult, GraphRAG, - MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - movies_vector_cypher_explain_formatter, + text2cypher_explain_result_formatter, ) from neo4j_graphrag.llm import OpenAILLM -from neo4j_graphrag.retrievers import VectorCypherRetriever +from neo4j_graphrag.retrievers import Text2CypherRetriever URI = "neo4j+s://demo.neo4jlabs.com" AUTH = ("recommendations", "recommendations") DATABASE = "recommendations" -INDEX = "moviePlotsEmbedding" DEFAULT_QUESTION = "Who were the actors in Avatar?" OPENAI_EXTRA_INSTALL_HINT = ( "This example requires the openai optional dependency.\n" "Install it with: uv sync --extra openai" ) +RECOMMENDATIONS_NEO4J_SCHEMA = """ +Node properties: +Actor {name: STRING} +Director {name: STRING} +Person {name: STRING, born: INTEGER} +Movie {tagline: STRING, title: STRING, released: INTEGER} +Relationship properties: +ACTED_IN {roles: LIST} +DIRECTED {} +REVIEWED {summary: STRING, rating: INTEGER} +The relationships: +(:Actor)-[:ACTED_IN]->(:Movie) +(:Person)-[:ACTED_IN]->(:Movie) +(:Person)-[:DIRECTED]->(:Movie) +(:Director)-[:DIRECTED]->(:Movie) +(:Person)-[:REVIEWED]->(:Movie) +""".strip() + +RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES = [ + ( + "USER INPUT: 'Which actors starred in the Matrix?' " + "QUERY: MATCH (p:Person)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'The Matrix' RETURN p.name AS name" + ), + ( + "USER INPUT: 'Who directed One Flew Over the Cuckoo\\'s Nest?' " + "QUERY: MATCH (p:Person)-[:DIRECTED]->(m:Movie) " + 'WHERE m.title = "One Flew Over the Cuckoo\'s Nest" RETURN p.name AS name' + ), + ( + "USER INPUT: 'Which movies did Joel Coen and Steve Buscemi work on together?' " + "QUERY: MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Actor) " + "WHERE d.name = 'Joel Coen' AND a.name = 'Steve Buscemi' RETURN m.title AS title" + ), +] + def ensure_openai_extra() -> None: try: @@ -51,58 +86,28 @@ def ensure_openai_extra() -> None: raise SystemExit(OPENAI_EXTRA_INSTALL_HINT) from exc -def build_rag(driver: neo4j.Driver) -> GraphRAG: - retriever = VectorCypherRetriever( +def build_rag(driver: neo4j.Driver, llm: OpenAILLM) -> GraphRAG: + retriever = Text2CypherRetriever( driver, - index_name=INDEX, - retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - result_formatter=movies_vector_cypher_explain_formatter, - embedder=OpenAIEmbeddings(), + llm=llm, + neo4j_schema=RECOMMENDATIONS_NEO4J_SCHEMA, + examples=RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES, + result_formatter=text2cypher_explain_result_formatter, neo4j_database=DATABASE, ) - llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0}) return GraphRAG(retriever=retriever, llm=llm) -def _node_label(node: dict[str, Any]) -> str: - labels = node.get("labels") or [] - name = (node.get("properties") or {}).get("name") or ( - node.get("properties") or {} - ).get("title") - if labels and name: - return f"{labels[0]}({name})" - if labels: - return labels[0] - if name: - return str(name) - return "node" - - -def _format_path(path: list[dict[str, Any]]) -> str: - parts: list[str] = [] - for element in path: - if "type" in element: - parts.append(f"-[:{element['type']}]->") - else: - parts.append(_node_label(element)) - return " ".join(parts) - - def format_explain_table(explain: ExplainResult) -> str: - lines = [f"Retriever: {explain.trace.retriever}", "", "Sources:"] + lines = [f"Retriever: {explain.trace.retriever}"] + if explain.trace.cypher: + lines.extend(["", "Generated Cypher:", f" {explain.trace.cypher}"]) + lines.extend(["", "Sources:"]) + if not explain.sources: + lines.append(" (no rows returned)") for source in explain.sources: score = f" (score={source.score:.3f})" if source.score is not None else "" lines.append(f" [{source.index}]{score} {source.content}") - - if explain.graph: - lines.extend(["", "Graph context:"]) - for index, context in enumerate(explain.graph, start=1): - lines.append(f" Source {index}:") - if context.seed_node is not None: - lines.append(f" seed: {_node_label(context.seed_node.model_dump())}") - for path_index, path in enumerate(context.paths, start=1): - serialized_path = [element.model_dump() for element in path] - lines.append(f" path {path_index}: {_format_path(serialized_path)}") return "\n".join(lines) @@ -133,24 +138,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="table", help="Output format (default: table)", ) - parser.add_argument( - "--top-k", - type=int, - default=3, - help="Number of retrieval results to pass to the LLM (default: 3)", - ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) ensure_openai_extra() + llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0}) with neo4j.GraphDatabase.driver(URI, auth=AUTH) as driver: - rag = build_rag(driver) + rag = build_rag(driver, llm) result = rag.search( args.question, explain=ExplainConfig() if args.explain else None, - retriever_config={"top_k": args.top_k}, ) if args.format == "json": diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 8b0481700..7282be06e 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -16,6 +16,7 @@ serialize_neo4j_path, serialize_paths, sources_from_retriever, + text2cypher_explain_result_formatter, trace_from_retriever, vector_cypher_explain_result_formatter, ) @@ -44,6 +45,7 @@ "serialize_neo4j_path", "serialize_paths", "sources_from_retriever", + "text2cypher_explain_result_formatter", "trace_from_retriever", "vector_cypher_explain_result_formatter", ] diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 30b2c7da5..0c5987c59 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -26,11 +26,14 @@ MATCH path = (actor:Actor)-[:ACTED_IN]->(node) WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) +WITH node, score, actors, actorPaths, + [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, + [path IN collect(directorPath) WHERE path IS NOT NULL] AS directorPaths RETURN node.title AS movieTitle, node.plot AS moviePlot, actors, - [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, - actorPaths + [path IN collect(directorPath) WHERE path IS NOT NULL] AS paths, + directors, + actorPaths + directorPaths AS paths, score AS similarityScore """.strip() @@ -84,6 +87,7 @@ class TraceStep(BaseModel): """Minimal execution trace for a GraphRAG search.""" retriever: str + cypher: str | None = None class ExplainResult(BaseModel): @@ -112,7 +116,11 @@ def trace_from_retriever(retriever_result: RetrieverResult) -> TraceStep: raise TypeError("retriever_result must be a RetrieverResult") metadata = retriever_result.metadata or {} retriever = metadata.get("__retriever", "unknown") - return TraceStep(retriever=str(retriever)) + cypher = metadata.get("cypher") + return TraceStep( + retriever=str(retriever), + cypher=str(cypher) if cypher is not None else None, + ) def sources_from_retriever(retriever_result: RetrieverResult) -> list[SourceRef]: @@ -514,3 +522,16 @@ def movies_vector_cypher_explain_formatter( f"Actors: {actors_text}, Directors: {directors_text}" ) return vector_cypher_explain_result_formatter(record, content=content) + + +def text2cypher_explain_result_formatter( + record: neo4j.Record, +) -> RetrieverResultItem: + from neo4j_graphrag.types import RetrieverResultItem + + parts: list[str] = [] + for key in record.keys(): + value = record.get(key) + if value is not None: + parts.append(f"{key}: {value}") + return RetrieverResultItem(content=", ".join(parts) if parts else str(record)) diff --git a/src/neo4j_graphrag/generation/graphrag.py b/src/neo4j_graphrag/generation/graphrag.py index c97e713f8..b026ec8c1 100644 --- a/src/neo4j_graphrag/generation/graphrag.py +++ b/src/neo4j_graphrag/generation/graphrag.py @@ -165,6 +165,14 @@ def search( ) if len(retriever_result.items) == 0 and response_fallback is not None: answer = response_fallback + elif ( + len(retriever_result.items) == 0 + and validated_data.explain is not None + and validated_data.explain.cite_sources + ): + answer = ( + "I could not find any matching results in the graph for this question." + ) else: cite_sources = ( validated_data.explain.cite_sources diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index 5f4ebc2b0..d471c44f9 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -73,6 +73,22 @@ def test_trace_from_retriever_uses_retriever_metadata() -> None: trace = trace_from_retriever(result) assert trace.retriever == "VectorCypherRetriever" + assert trace.cypher is None + + +def test_trace_from_retriever_includes_generated_cypher() -> None: + result = RetrieverResult( + items=[], + metadata={ + "__retriever": "Text2CypherRetriever", + "cypher": "MATCH (m:Movie) RETURN m.title AS title", + }, + ) + + trace = trace_from_retriever(result) + + assert trace.retriever == "Text2CypherRetriever" + assert trace.cypher == "MATCH (m:Movie) RETURN m.title AS title" def test_trace_from_retriever_defaults_to_unknown() -> None: @@ -222,6 +238,27 @@ def test_graphrag_search_without_explain_unchanged( assert "[1]" not in llm.invoke.call_args.kwargs["input"] +def test_graphrag_search_with_explain_skips_llm_when_no_rows( + retriever_mock: MagicMock, llm: MagicMock +) -> None: + rag = GraphRAG(retriever=retriever_mock, llm=llm) + retriever_mock.search.return_value = RetrieverResult( + items=[], + metadata={ + "__retriever": "Text2CypherRetriever", + "cypher": "MATCH (m:Movie) RETURN m.title AS title", + }, + ) + + result = rag.search("question", explain=ExplainConfig()) + + assert "could not find any matching results" in result.answer.lower() + assert result.explain is not None + assert result.explain.trace.cypher == "MATCH (m:Movie) RETURN m.title AS title" + assert result.explain.sources == [] + llm.invoke.assert_not_called() + + def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: record = neo4j.Record( { @@ -393,3 +430,13 @@ def test_build_explain_result_from_movies_formatter_output() -> None: assert explain.graph is not None assert explain.graph[0].paths[0][1].type == "ACTED_IN" assert explain.sources[0].score == 0.91 + + +def test_text2cypher_explain_result_formatter_formats_record() -> None: + from neo4j_graphrag.generation.explain import text2cypher_explain_result_formatter + + record = neo4j.Record({"title": "One Flew Over the Cuckoo's Nest"}) + + item = text2cypher_explain_result_formatter(record) + + assert item.content == "title: One Flew Over the Cuckoo's Nest" From a0201e3bc2f6c4ead03b550cb630042bec772c38 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:48:08 +0200 Subject: [PATCH 08/20] Generic graph extraction from Text2Cypher result rows --- docs/source/user_guide_rag.rst | 6 +- .../graphrag_with_explain.py | 38 +++++- src/neo4j_graphrag/generation/__init__.py | 2 + src/neo4j_graphrag/generation/explain.py | 124 +++++++++++++++++- tests/unit/test_explain.py | 40 ++++++ 5 files changed, 199 insertions(+), 11 deletions(-) diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 1a655efa5..17d058f03 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1460,8 +1460,10 @@ The payload includes numbered sources (aligned with LLM context), optional graph neighborhood and paths from the retriever, and a minimal retriever trace. For ask-anything graph questions, pair explain with ``Text2CypherRetriever`` and -``text2cypher_explain_result_formatter``. The generated Cypher query is available -on ``result.explain.trace.cypher``. +``text2cypher_explain_result_formatter``. Return Cypher paths or nodes in the +query results (for example ``RETURN m.title AS title, path AS path``) so +``result.explain.graph`` can be populated. The generated Cypher query is also +available on ``result.explain.trace.cypher``. .. code:: python diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index f768e3429..6de7bc738 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -63,18 +63,21 @@ RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES = [ ( "USER INPUT: 'Which actors starred in the Matrix?' " - "QUERY: MATCH (p:Person)-[:ACTED_IN]->(m:Movie) " - "WHERE m.title = 'The Matrix' RETURN p.name AS name" + "QUERY: MATCH path = (p:Person)-[:ACTED_IN]->(m:Movie) " + "WHERE m.title = 'The Matrix' " + "RETURN p.name AS name, path AS path" ), ( "USER INPUT: 'Who directed One Flew Over the Cuckoo\\'s Nest?' " - "QUERY: MATCH (p:Person)-[:DIRECTED]->(m:Movie) " - 'WHERE m.title = "One Flew Over the Cuckoo\'s Nest" RETURN p.name AS name' + "QUERY: MATCH path = (p:Person)-[:DIRECTED]->(m:Movie) " + 'WHERE m.title = "One Flew Over the Cuckoo\'s Nest" ' + "RETURN p.name AS name, path AS path" ), ( "USER INPUT: 'Which movies did Joel Coen and Steve Buscemi work on together?' " - "QUERY: MATCH (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Actor) " - "WHERE d.name = 'Joel Coen' AND a.name = 'Steve Buscemi' RETURN m.title AS title" + "QUERY: MATCH path = (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Actor) " + "WHERE d.name = 'Joel Coen' AND a.name = 'Steve Buscemi' " + "RETURN m.title AS title, path AS path" ), ] @@ -108,6 +111,29 @@ def format_explain_table(explain: ExplainResult) -> str: for source in explain.sources: score = f" (score={source.score:.3f})" if source.score is not None else "" lines.append(f" [{source.index}]{score} {source.content}") + + if explain.graph: + lines.extend(["", "Graph context:"]) + for index, context in enumerate(explain.graph, start=1): + lines.append(f" Source {index}:") + if context.seed_node is not None: + seed = context.seed_node + label = seed.labels[0] if seed.labels else "node" + name = seed.properties.get("title") or seed.properties.get("name") + seed_text = f"{label}({name})" if name else label + lines.append(f" seed: {seed_text}") + for path_index, path in enumerate(context.paths, start=1): + parts: list[str] = [] + for element in path: + if hasattr(element, "type"): + parts.append(f"-[:{element.type}]->") + else: + label = element.labels[0] if element.labels else "node" + name = element.properties.get("name") or element.properties.get( + "title" + ) + parts.append(f"{label}({name})" if name else label) + lines.append(f" path {path_index}: {' '.join(parts)}") return "\n".join(lines) diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 7282be06e..53080d1e7 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -11,6 +11,7 @@ build_explain_result, format_retrieval_context, graph_and_paths_from_record, + graph_context_from_neo4j_record, graph_from_retriever, movies_vector_cypher_explain_formatter, serialize_neo4j_path, @@ -40,6 +41,7 @@ "build_explain_result", "format_retrieval_context", "graph_and_paths_from_record", + "graph_context_from_neo4j_record", "graph_from_retriever", "movies_vector_cypher_explain_formatter", "serialize_neo4j_path", diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 0c5987c59..dcbb10f71 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -361,6 +361,8 @@ def serialize_neo4j_path(path: neo4j.graph.Path) -> GraphPath: def serialize_paths(paths_value: Any) -> list[GraphPath]: if not paths_value or not isinstance(paths_value, list): + if isinstance(paths_value, neo4j.graph.Path): + return [serialize_neo4j_path(paths_value)] return [] paths: list[GraphPath] = [] for path in paths_value: @@ -374,6 +376,96 @@ def serialize_paths(paths_value: Any) -> list[GraphPath]: return paths +_GRAPH_RECORD_KEYS = frozenset({"path", "paths", "graph"}) + + +def _collect_paths_from_record(record: neo4j.Record) -> list[GraphPath]: + paths: list[GraphPath] = [] + for key in record.keys(): + value = record.get(key) + if isinstance(value, neo4j.graph.Path): + paths.append(serialize_neo4j_path(value)) + continue + if key in _GRAPH_RECORD_KEYS or key.endswith("Path"): + paths.extend(serialize_paths(value)) + continue + if isinstance(value, list): + paths.extend(serialize_paths(value)) + return paths + + +def _seed_node_from_record_scalars(record: neo4j.Record) -> GraphNodeRef | None: + for key in ("movie", "m", "node"): + value = record.get(key) + if isinstance(value, neo4j.graph.Node): + return _node_from_neo4j_graph_node(value) + title = record.get("title") or record.get("movieTitle") + name = record.get("name") + if title is not None: + return GraphNodeRef(labels=["Movie"], properties={"title": str(title)}) + if name is not None: + return GraphNodeRef(labels=["Person"], properties={"name": str(name)}) + return None + + +def _graph_elements_from_paths( + paths: list[GraphPath], +) -> tuple[GraphNodeRef | None, list[GraphNodeRef], list[GraphRelationshipRef]]: + seed_node: GraphNodeRef | None = None + related_nodes: list[GraphNodeRef] = [] + relationships: list[GraphRelationshipRef] = [] + seen_node_ids: set[str] = set() + seen_rel_keys: set[tuple[str, str | None, str | None]] = set() + + for path in paths: + for element in path: + if isinstance(element, GraphNodeRef): + node_key = element.id or str(element.properties) + if "Movie" in element.labels: + if seed_node is None: + seed_node = element + continue + if node_key not in seen_node_ids: + related_nodes.append(element) + seen_node_ids.add(node_key) + continue + rel_key = (element.type, element.start_id, element.end_id) + if rel_key not in seen_rel_keys: + relationships.append(element) + seen_rel_keys.add(rel_key) + + if seed_node is None: + for path in paths: + for element in path: + if isinstance(element, GraphNodeRef): + seed_node = element + break + if seed_node is not None: + break + + return seed_node, related_nodes, relationships + + +def graph_context_from_neo4j_record(record: neo4j.Record) -> dict[str, Any] | None: + """Build metadata.graph from Neo4j paths or nodes returned by Text2Cypher.""" + paths = _collect_paths_from_record(record) + if paths: + seed_node, related_nodes, relationships = _graph_elements_from_paths(paths) + if seed_node is None: + seed_node = _seed_node_from_record_scalars(record) + return GraphContext( + seed_node=seed_node, + related_nodes=related_nodes, + relationships=relationships, + paths=paths, + ).model_dump(exclude_none=True) + + seed_node = _seed_node_from_record_scalars(record) + if seed_node is None: + return None + return GraphContext(seed_node=seed_node).model_dump(exclude_none=True) + + def graph_and_paths_from_record( record: neo4j.Record, *, @@ -526,12 +618,38 @@ def movies_vector_cypher_explain_formatter( def text2cypher_explain_result_formatter( record: neo4j.Record, + *, + graph_builder: Callable[[neo4j.Record], dict[str, Any] | None] | None = None, ) -> RetrieverResultItem: from neo4j_graphrag.types import RetrieverResultItem parts: list[str] = [] for key in record.keys(): + if key in _GRAPH_RECORD_KEYS: + continue value = record.get(key) - if value is not None: - parts.append(f"{key}: {value}") - return RetrieverResultItem(content=", ".join(parts) if parts else str(record)) + if value is None: + continue + if isinstance( + value, (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) + ): + continue + if ( + isinstance(value, list) + and value + and isinstance( + value[0], (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) + ) + ): + continue + parts.append(f"{key}: {value}") + + metadata: dict[str, Any] = {} + graph = (graph_builder or graph_context_from_neo4j_record)(record) + if graph: + metadata["graph"] = graph + + return RetrieverResultItem( + content=", ".join(parts) if parts else str(record), + metadata=metadata, + ) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index d471c44f9..784b8c3d1 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -440,3 +440,43 @@ def test_text2cypher_explain_result_formatter_formats_record() -> None: item = text2cypher_explain_result_formatter(record) assert item.content == "title: One Flew Over the Cuckoo's Nest" + assert item.metadata is not None + assert item.metadata["graph"]["seed_node"]["properties"]["title"] == ( + "One Flew Over the Cuckoo's Nest" + ) + + +def test_text2cypher_explain_result_formatter_includes_graph_paths() -> None: + from neo4j_graphrag.generation.explain import text2cypher_explain_result_formatter + + director = MagicMock(spec=neo4j.graph.Node) + director.element_id = "4:director" + director.labels = ["Person"] + director.items.return_value = {"name": "Joel Coen"}.items() + actor = MagicMock(spec=neo4j.graph.Node) + actor.element_id = "4:actor" + actor.labels = ["Actor"] + actor.items.return_value = {"name": "Steve Buscemi"}.items() + movie = MagicMock(spec=neo4j.graph.Node) + movie.element_id = "4:movie" + movie.labels = ["Movie"] + movie.items.return_value = {"title": "Fargo"}.items() + directed = MagicMock(spec=neo4j.graph.Relationship) + directed.type = "DIRECTED" + directed.start_node = director + directed.end_node = movie + acted = MagicMock(spec=neo4j.graph.Relationship) + acted.type = "ACTED_IN" + acted.start_node = actor + acted.end_node = movie + path = MagicMock(spec=neo4j.graph.Path) + path.nodes = [director, movie, actor] + path.relationships = [directed, acted] + record = neo4j.Record({"title": "Fargo", "path": path}) + + item = text2cypher_explain_result_formatter(record) + + assert item.content == "title: Fargo" + assert item.metadata is not None + assert len(item.metadata["graph"]["paths"]) == 1 + assert item.metadata["graph"]["paths"][0][1]["type"] == "DIRECTED" From 6886f08c2639c81a089475e5c74c3d6b0021f3d2 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 11:53:13 +0200 Subject: [PATCH 09/20] Separate demo logic from library --- docs/source/user_guide_rag.rst | 12 +- src/neo4j_graphrag/generation/__init__.py | 8 +- src/neo4j_graphrag/generation/explain.py | 159 +-------------- .../generation/explain_recommendations.py | 181 ++++++++++++++++++ tests/unit/test_explain.py | 120 ------------ tests/unit/test_explain_recommendations.py | 141 ++++++++++++++ 6 files changed, 340 insertions(+), 281 deletions(-) create mode 100644 src/neo4j_graphrag/generation/explain_recommendations.py create mode 100644 tests/unit/test_explain_recommendations.py diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 17d058f03..d437cfad1 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1491,15 +1491,15 @@ available on ``result.explain.trace.cypher``. For graph-path provenance from vector similarity hits, use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each retrieved item. -The package provides helpers such as ``movies_vector_cypher_explain_formatter`` -and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY`` for the public recommendations demo -database. +The recommendations demo database has helpers in +``neo4j_graphrag.generation.explain_recommendations`` such as +``movies_vector_cypher_explain_formatter`` and +``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY``. .. code:: python - from neo4j_graphrag.generation import ( - ExplainConfig, - GraphRAG, + from neo4j_graphrag.generation import ExplainConfig, GraphRAG + from neo4j_graphrag.generation.explain_recommendations import ( MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, movies_vector_cypher_explain_formatter, ) diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 53080d1e7..740530149 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -5,15 +5,12 @@ GraphNodeRef, GraphPath, GraphRelationshipRef, - MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, SourceRef, TraceStep, build_explain_result, format_retrieval_context, - graph_and_paths_from_record, graph_context_from_neo4j_record, graph_from_retriever, - movies_vector_cypher_explain_formatter, serialize_neo4j_path, serialize_paths, sources_from_retriever, @@ -21,6 +18,11 @@ trace_from_retriever, vector_cypher_explain_result_formatter, ) +from .explain_recommendations import ( + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + graph_and_paths_from_record, + movies_vector_cypher_explain_formatter, +) from .graphrag import GraphRAG from .prompts import PromptTemplate, RagTemplate, SchemaExtractionTemplate diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index dcbb10f71..b3d5580ac 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -22,21 +22,6 @@ if TYPE_CHECKING: from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem -MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ -MATCH path = (actor:Actor)-[:ACTED_IN]->(node) -WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths -OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) -WITH node, score, actors, actorPaths, - [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, - [path IN collect(directorPath) WHERE path IS NOT NULL] AS directorPaths -RETURN node.title AS movieTitle, - node.plot AS moviePlot, - actors, - directors, - actorPaths + directorPaths AS paths, - score AS similarityScore -""".strip() - GraphPathElement = Union["GraphNodeRef", "GraphRelationshipRef"] GraphPath = list[GraphPathElement] @@ -466,154 +451,24 @@ def graph_context_from_neo4j_record(record: neo4j.Record) -> dict[str, Any] | No return GraphContext(seed_node=seed_node).model_dump(exclude_none=True) -def graph_and_paths_from_record( - record: neo4j.Record, - *, - node_key: str = "node", - title_key: str = "movieTitle", - plot_key: str = "moviePlot", - actors_key: str = "actors", - directors_key: str = "directors", - paths_key: str = "paths", - movie_labels: list[str] | None = None, - actor_labels: list[str] | None = None, - director_labels: list[str] | None = None, -) -> dict[str, Any]: - """Build a metadata.graph mapping from a VectorCypher retrieval row.""" - movie_label_list = movie_labels or ["Movie"] - actor_label_list = actor_labels or ["Actor"] - director_label_list = director_labels or ["Person"] - - seed_node: GraphNodeRef | None = None - node = record.get(node_key) - if isinstance(node, neo4j.graph.Node): - seed_node = _node_from_neo4j_graph_node(node) - else: - title = record.get(title_key) - plot = record.get(plot_key) - if title is not None or plot is not None: - properties: dict[str, Any] = {} - if title is not None: - properties["title"] = title - if plot is not None: - properties["plot"] = plot - seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) - - related_nodes: list[GraphNodeRef] = [] - relationships: list[GraphRelationshipRef] = [] - actors = record.get(actors_key) or [] - if isinstance(actors, list): - for actor_name in actors: - if actor_name is None: - continue - actor_node = GraphNodeRef( - labels=actor_label_list, - properties={"name": str(actor_name)}, - ) - related_nodes.append(actor_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="ACTED_IN", - start_id=None, - end_id=seed_node.id, - ) - ) - - directors = record.get(directors_key) or [] - if isinstance(directors, list): - for director_name in directors: - if director_name is None: - continue - director_node = GraphNodeRef( - labels=director_label_list, - properties={"name": str(director_name)}, - ) - related_nodes.append(director_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="DIRECTED", - start_id=None, - end_id=seed_node.id, - ) - ) - - paths = serialize_paths(record.get(paths_key)) - if not paths and seed_node is not None and related_nodes: - for person_name, rel_type, labels in ( - *( - (name, "ACTED_IN", actor_label_list) - for name in actors - if isinstance(actors, list) and name is not None - ), - *( - (name, "DIRECTED", director_label_list) - for name in directors - if isinstance(directors, list) and name is not None - ), - ): - person_node = GraphNodeRef( - labels=labels, - properties={"name": str(person_name)}, - ) - paths.append( - [ - person_node, - GraphRelationshipRef(type=rel_type), - seed_node, - ] - ) - - return GraphContext( - seed_node=seed_node, - related_nodes=related_nodes, - relationships=relationships, - paths=paths, - ).model_dump(exclude_none=True) - - def vector_cypher_explain_result_formatter( record: neo4j.Record, *, content: str, score_key: str = "similarityScore", - graph_builder: Callable[[neo4j.Record], dict[str, Any]] | None = None, + graph_builder: Callable[[neo4j.Record], dict[str, Any] | None] | None = None, ) -> RetrieverResultItem: from neo4j_graphrag.types import RetrieverResultItem - graph = (graph_builder or graph_and_paths_from_record)(record) + metadata: dict[str, Any] = {"score": record.get(score_key)} + if graph_builder is not None: + graph = graph_builder(record) + if graph: + metadata["graph"] = graph return RetrieverResultItem( content=content, - metadata={ - "score": record.get(score_key), - "graph": graph, - }, - ) - - -def movies_vector_cypher_explain_formatter( - record: neo4j.Record, -) -> RetrieverResultItem: - actors = record.get("actors") or [] - if isinstance(actors, list): - actors_text = ", ".join(str(actor) for actor in actors if actor is not None) - else: - actors_text = str(actors) - directors = record.get("directors") or [] - if isinstance(directors, list): - directors_text = ", ".join( - str(director) for director in directors if director is not None - ) - else: - directors_text = str(directors) - title = record.get("movieTitle") - plot = record.get("moviePlot") - content = ( - f"Movie title: {title}, Plot: {plot}, " - f"Actors: {actors_text}, Directors: {directors_text}" + metadata=metadata, ) - return vector_cypher_explain_result_formatter(record, content=content) def text2cypher_explain_result_formatter( diff --git a/src/neo4j_graphrag/generation/explain_recommendations.py b/src/neo4j_graphrag/generation/explain_recommendations.py new file mode 100644 index 000000000..241311dad --- /dev/null +++ b/src/neo4j_graphrag/generation/explain_recommendations.py @@ -0,0 +1,181 @@ +# 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. +"""Recommendations demo helpers for GraphRAG explainability.""" + +from __future__ import annotations + +from typing import Any + +import neo4j + +from neo4j_graphrag.generation.explain import ( + GraphContext, + GraphNodeRef, + GraphRelationshipRef, + _node_from_neo4j_graph_node, + serialize_paths, + vector_cypher_explain_result_formatter, +) +from neo4j_graphrag.types import RetrieverResultItem + +MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ +MATCH path = (actor:Actor)-[:ACTED_IN]->(node) +WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths +OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) +WITH node, score, actors, actorPaths, + [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, + [path IN collect(directorPath) WHERE path IS NOT NULL] AS directorPaths +RETURN node.title AS movieTitle, + node.plot AS moviePlot, + actors, + directors, + actorPaths + directorPaths AS paths, + score AS similarityScore +""".strip() + + +def graph_and_paths_from_record( + record: neo4j.Record, + *, + node_key: str = "node", + title_key: str = "movieTitle", + plot_key: str = "moviePlot", + actors_key: str = "actors", + directors_key: str = "directors", + paths_key: str = "paths", + movie_labels: list[str] | None = None, + actor_labels: list[str] | None = None, + director_labels: list[str] | None = None, +) -> dict[str, Any]: + """Build a metadata.graph mapping from a recommendations VectorCypher row.""" + movie_label_list = movie_labels or ["Movie"] + actor_label_list = actor_labels or ["Actor"] + director_label_list = director_labels or ["Person"] + + seed_node: GraphNodeRef | None = None + node = record.get(node_key) + if isinstance(node, neo4j.graph.Node): + seed_node = _node_from_neo4j_graph_node(node) + else: + title = record.get(title_key) + plot = record.get(plot_key) + if title is not None or plot is not None: + properties: dict[str, Any] = {} + if title is not None: + properties["title"] = title + if plot is not None: + properties["plot"] = plot + seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) + + related_nodes: list[GraphNodeRef] = [] + relationships: list[GraphRelationshipRef] = [] + actors = record.get(actors_key) or [] + if isinstance(actors, list): + for actor_name in actors: + if actor_name is None: + continue + actor_node = GraphNodeRef( + labels=actor_label_list, + properties={"name": str(actor_name)}, + ) + related_nodes.append(actor_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="ACTED_IN", + start_id=None, + end_id=seed_node.id, + ) + ) + + directors = record.get(directors_key) or [] + if isinstance(directors, list): + for director_name in directors: + if director_name is None: + continue + director_node = GraphNodeRef( + labels=director_label_list, + properties={"name": str(director_name)}, + ) + related_nodes.append(director_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="DIRECTED", + start_id=None, + end_id=seed_node.id, + ) + ) + + paths = serialize_paths(record.get(paths_key)) + if not paths and seed_node is not None and related_nodes: + for person_name, rel_type, labels in ( + *( + (name, "ACTED_IN", actor_label_list) + for name in actors + if isinstance(actors, list) and name is not None + ), + *( + (name, "DIRECTED", director_label_list) + for name in directors + if isinstance(directors, list) and name is not None + ), + ): + person_node = GraphNodeRef( + labels=labels, + properties={"name": str(person_name)}, + ) + paths.append( + [ + person_node, + GraphRelationshipRef(type=rel_type), + seed_node, + ] + ) + + return GraphContext( + seed_node=seed_node, + related_nodes=related_nodes, + relationships=relationships, + paths=paths, + ).model_dump(exclude_none=True) + + +def movies_vector_cypher_explain_formatter( + record: neo4j.Record, +) -> RetrieverResultItem: + actors = record.get("actors") or [] + if isinstance(actors, list): + actors_text = ", ".join(str(actor) for actor in actors if actor is not None) + else: + actors_text = str(actors) + directors = record.get("directors") or [] + if isinstance(directors, list): + directors_text = ", ".join( + str(director) for director in directors if director is not None + ) + else: + directors_text = str(directors) + title = record.get("movieTitle") + plot = record.get("moviePlot") + content = ( + f"Movie title: {title}, Plot: {plot}, " + f"Actors: {actors_text}, Directors: {directors_text}" + ) + return vector_cypher_explain_result_formatter( + record, + content=content, + graph_builder=graph_and_paths_from_record, + ) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index 784b8c3d1..36105593d 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -23,9 +23,7 @@ TraceStep, build_explain_result, format_retrieval_context, - graph_and_paths_from_record, graph_from_retriever, - movies_vector_cypher_explain_formatter, serialize_neo4j_path, sources_from_retriever, trace_from_retriever, @@ -259,47 +257,6 @@ def test_graphrag_search_with_explain_skips_llm_when_no_rows( llm.invoke.assert_not_called() -def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: - record = neo4j.Record( - { - "movieTitle": "One Flew Over the Cuckoo's Nest", - "moviePlot": "A criminal pleads insanity.", - "actors": ["Michael Berryman"], - "directors": ["Milos Forman"], - "paths": None, - } - ) - - graph = graph_and_paths_from_record(record) - - assert ( - graph["seed_node"]["properties"]["title"] == "One Flew Over the Cuckoo's Nest" - ) - assert len(graph["related_nodes"]) == 2 - assert len(graph["paths"]) == 2 - assert graph["paths"][0][1]["type"] == "ACTED_IN" - assert graph["paths"][1][1]["type"] == "DIRECTED" - - -def test_graph_and_paths_from_record_builds_seed_and_actors() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana", "Sam Worthington"], - "paths": None, - } - ) - - graph = graph_and_paths_from_record(record) - - assert graph["seed_node"]["properties"]["title"] == "Avatar" - assert len(graph["related_nodes"]) == 2 - assert graph["related_nodes"][0]["properties"]["name"] == "Zoe Saldana" - assert len(graph["paths"]) == 2 - assert graph["paths"][0][1]["type"] == "ACTED_IN" - - def test_serialize_neo4j_path_from_graph_objects() -> None: actor = MagicMock(spec=neo4j.graph.Node) actor.element_id = "4:actor" @@ -355,83 +312,6 @@ def test_node_from_neo4j_graph_node_serializes_temporal_properties() -> None: ).model_dump(mode="json") -def test_graph_and_paths_from_record_uses_neo4j_paths() -> None: - actor = MagicMock(spec=neo4j.graph.Node) - actor.element_id = "4:actor" - actor.labels = ["Actor"] - actor.items.return_value = {"name": "Zoe Saldana"}.items() - movie = MagicMock(spec=neo4j.graph.Node) - movie.element_id = "4:movie" - movie.labels = ["Movie"] - movie.items.return_value = {"title": "Avatar"}.items() - relationship = MagicMock(spec=neo4j.graph.Relationship) - relationship.type = "ACTED_IN" - relationship.start_node = actor - relationship.end_node = movie - path = MagicMock(spec=neo4j.graph.Path) - path.nodes = [actor, movie] - path.relationships = [relationship] - record = neo4j.Record( - { - "node": movie, - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "paths": [path], - } - ) - - graph = graph_and_paths_from_record(record) - - assert graph["seed_node"]["id"] == "4:movie" - assert len(graph["paths"]) == 1 - assert graph["paths"][0][0]["id"] == "4:actor" - assert graph["paths"][0][1]["start_id"] == "4:actor" - - -def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "directors": ["James Cameron"], - "similarityScore": 0.91, - "paths": None, - } - ) - - item = movies_vector_cypher_explain_formatter(record) - - assert "Avatar" in item.content - assert "James Cameron" in item.content - assert item.metadata is not None - assert item.metadata["score"] == 0.91 - assert item.metadata["graph"]["seed_node"]["properties"]["title"] == "Avatar" - - -def test_build_explain_result_from_movies_formatter_output() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "similarityScore": 0.91, - "paths": None, - } - ) - result = RetrieverResult( - items=[movies_vector_cypher_explain_formatter(record)], - metadata={"__retriever": "VectorCypherRetriever"}, - ) - - explain = build_explain_result(result) - - assert explain.graph is not None - assert explain.graph[0].paths[0][1].type == "ACTED_IN" - assert explain.sources[0].score == 0.91 - - def test_text2cypher_explain_result_formatter_formats_record() -> None: from neo4j_graphrag.generation.explain import text2cypher_explain_result_formatter diff --git a/tests/unit/test_explain_recommendations.py b/tests/unit/test_explain_recommendations.py new file mode 100644 index 000000000..98ec89f41 --- /dev/null +++ b/tests/unit/test_explain_recommendations.py @@ -0,0 +1,141 @@ +# 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 + +import neo4j +from neo4j_graphrag.generation.explain import build_explain_result +from neo4j_graphrag.generation.explain_recommendations import ( + graph_and_paths_from_record, + movies_vector_cypher_explain_formatter, +) +from neo4j_graphrag.types import RetrieverResult + + +def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: + record = neo4j.Record( + { + "movieTitle": "One Flew Over the Cuckoo's Nest", + "moviePlot": "A criminal pleads insanity.", + "actors": ["Michael Berryman"], + "directors": ["Milos Forman"], + "paths": None, + } + ) + + graph = graph_and_paths_from_record(record) + + assert ( + graph["seed_node"]["properties"]["title"] == "One Flew Over the Cuckoo's Nest" + ) + assert len(graph["related_nodes"]) == 2 + assert len(graph["paths"]) == 2 + assert graph["paths"][0][1]["type"] == "ACTED_IN" + assert graph["paths"][1][1]["type"] == "DIRECTED" + + +def test_graph_and_paths_from_record_builds_seed_and_actors() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana", "Sam Worthington"], + "paths": None, + } + ) + + graph = graph_and_paths_from_record(record) + + assert graph["seed_node"]["properties"]["title"] == "Avatar" + assert len(graph["related_nodes"]) == 2 + assert graph["related_nodes"][0]["properties"]["name"] == "Zoe Saldana" + assert len(graph["paths"]) == 2 + assert graph["paths"][0][1]["type"] == "ACTED_IN" + + +def test_graph_and_paths_from_record_uses_neo4j_paths() -> None: + actor = MagicMock(spec=neo4j.graph.Node) + actor.element_id = "4:actor" + actor.labels = ["Actor"] + actor.items.return_value = {"name": "Zoe Saldana"}.items() + movie = MagicMock(spec=neo4j.graph.Node) + movie.element_id = "4:movie" + movie.labels = ["Movie"] + movie.items.return_value = {"title": "Avatar"}.items() + relationship = MagicMock(spec=neo4j.graph.Relationship) + relationship.type = "ACTED_IN" + relationship.start_node = actor + relationship.end_node = movie + path = MagicMock(spec=neo4j.graph.Path) + path.nodes = [actor, movie] + path.relationships = [relationship] + record = neo4j.Record( + { + "node": movie, + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "paths": [path], + } + ) + + graph = graph_and_paths_from_record(record) + + assert graph["seed_node"]["id"] == "4:movie" + assert len(graph["paths"]) == 1 + assert graph["paths"][0][0]["id"] == "4:actor" + assert graph["paths"][0][1]["start_id"] == "4:actor" + + +def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "directors": ["James Cameron"], + "similarityScore": 0.91, + "paths": None, + } + ) + + item = movies_vector_cypher_explain_formatter(record) + + assert "Avatar" in item.content + assert "James Cameron" in item.content + assert item.metadata is not None + assert item.metadata["score"] == 0.91 + assert item.metadata["graph"]["seed_node"]["properties"]["title"] == "Avatar" + + +def test_build_explain_result_from_movies_formatter_output() -> None: + record = neo4j.Record( + { + "movieTitle": "Avatar", + "moviePlot": "A marine on an alien planet.", + "actors": ["Zoe Saldana"], + "similarityScore": 0.91, + "paths": None, + } + ) + result = RetrieverResult( + items=[movies_vector_cypher_explain_formatter(record)], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + + explain = build_explain_result(result) + + assert explain.graph is not None + assert explain.graph[0].paths[0][1].type == "ACTED_IN" + assert explain.sources[0].score == 0.91 From 28bb2500219ac239233c6fbbaedc7ae92a7721aa Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 13:11:42 +0200 Subject: [PATCH 10/20] Include VectorCypherRetriever to demo --- docs/source/user_guide_rag.rst | 4 +- .../graphrag_with_explain.py | 143 ++++++++++++++---- 2 files changed, 120 insertions(+), 27 deletions(-) diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index d437cfad1..51290de65 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1523,7 +1523,9 @@ The recommendations demo database has helpers in When ``explain`` is set, ``return_context`` is enabled automatically and sources are numbered in the LLM prompt so answers can cite ``[1]``, ``[2]``, and so on. -See also: ``examples/question_answering/graphrag_with_explain.py``. +See also: ``examples/question_answering/graphrag_with_explain.py`` for a runnable +demo with both Text2Cypher (``--retriever text2cypher``) and VectorCypher +(``--retriever vector-cypher``). This example uses OpenAI for embeddings and generation. Install the optional dependency before running it:: diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 6de7bc738..814482c11 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -1,8 +1,11 @@ """GraphRAG with optional explainability output. -Uses Text2Cypher against the public Neo4j recommendations demo database so you -can ask natural-language questions about the graph and inspect sources plus the -generated Cypher query. +Demonstrates explain on the public Neo4j recommendations demo database with either: + +- **text2cypher** (default): natural-language questions translated to Cypher; shows + generated query, sources, and graph paths. +- **vector-cypher**: plot-similarity search plus graph expansion; shows similarity + scores, sources, and actor/director paths for each hit. Requires OPENAI_API_KEY in the environment and the ``openai`` optional dependency:: @@ -13,7 +16,9 @@ uv run examples/question_answering/graphrag_with_explain.py uv run examples/question_answering/graphrag_with_explain.py --no-explain uv run examples/question_answering/graphrag_with_explain.py --format json \\ - "Which movies did Joel Coen and Steve Buscemi work on together?" + "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?" + uv run examples/question_answering/graphrag_with_explain.py --retriever vector-cypher \\ + "Find movies about a marine on an alien planet and list the cast and director." """ from __future__ import annotations @@ -21,22 +26,36 @@ import argparse import json import sys -from typing import Any +from typing import Any, Literal import neo4j +from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings from neo4j_graphrag.generation import ( ExplainConfig, ExplainResult, GraphRAG, text2cypher_explain_result_formatter, ) +from neo4j_graphrag.generation.explain_recommendations import ( + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + movies_vector_cypher_explain_formatter, +) from neo4j_graphrag.llm import OpenAILLM -from neo4j_graphrag.retrievers import Text2CypherRetriever +from neo4j_graphrag.retrievers import Text2CypherRetriever, VectorCypherRetriever URI = "neo4j+s://demo.neo4jlabs.com" AUTH = ("recommendations", "recommendations") DATABASE = "recommendations" -DEFAULT_QUESTION = "Who were the actors in Avatar?" +INDEX_NAME = "moviePlotsEmbedding" +RetrieverName = Literal["text2cypher", "vector-cypher"] +DEFAULT_QUESTIONS: dict[RetrieverName, str] = { + "text2cypher": ( + "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard as director?" + ), + "vector-cypher": ( + "Find movies about a marine on an alien planet and list the cast and director." + ), +} OPENAI_EXTRA_INSTALL_HINT = ( "This example requires the openai optional dependency.\n" "Install it with: uv sync --extra openai" @@ -47,38 +66,53 @@ Actor {name: STRING} Director {name: STRING} Person {name: STRING, born: INTEGER} +User {name: STRING, userId: STRING} +Genre {name: STRING} Movie {tagline: STRING, title: STRING, released: INTEGER} Relationship properties: ACTED_IN {roles: LIST} DIRECTED {} -REVIEWED {summary: STRING, rating: INTEGER} +RATED {rating: FLOAT, timestamp: INTEGER} The relationships: (:Actor)-[:ACTED_IN]->(:Movie) (:Person)-[:ACTED_IN]->(:Movie) (:Person)-[:DIRECTED]->(:Movie) (:Director)-[:DIRECTED]->(:Movie) -(:Person)-[:REVIEWED]->(:Movie) +(:User)-[:RATED]->(:Movie) +(:Movie)-[:IN_GENRE]->(:Genre) """.strip() RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES = [ - ( - "USER INPUT: 'Which actors starred in the Matrix?' " - "QUERY: MATCH path = (p:Person)-[:ACTED_IN]->(m:Movie) " - "WHERE m.title = 'The Matrix' " - "RETURN p.name AS name, path AS path" - ), - ( - "USER INPUT: 'Who directed One Flew Over the Cuckoo\\'s Nest?' " - "QUERY: MATCH path = (p:Person)-[:DIRECTED]->(m:Movie) " - 'WHERE m.title = "One Flew Over the Cuckoo\'s Nest" ' - "RETURN p.name AS name, path AS path" - ), ( "USER INPUT: 'Which movies did Joel Coen and Steve Buscemi work on together?' " "QUERY: MATCH path = (d:Person)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Actor) " "WHERE d.name = 'Joel Coen' AND a.name = 'Steve Buscemi' " "RETURN m.title AS title, path AS path" ), + ( + "USER INPUT: 'Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?' " + "QUERY: MATCH path = (hanks:Actor)-[:ACTED_IN]->(m1:Movie)<-[:DIRECTED]-" + "(d:Person)-[:DIRECTED]->(m2:Movie)<-[:ACTED_IN]-(bacon:Actor) " + "WHERE hanks.name = 'Tom Hanks' AND d.name = 'Ron Howard' " + "AND bacon.name = 'Kevin Bacon' " + "RETURN m1.title AS hanks_movie, m2.title AS bacon_movie, path AS path" + ), + ( + "USER INPUT: 'Which other movies in the same genre as Avatar did Sam Worthington " + "also act in?' " + "QUERY: MATCH path = (a:Actor)-[:ACTED_IN]->(avatar:Movie)-[:IN_GENRE]->(g:Genre)" + "<-[:IN_GENRE]-(other:Movie)<-[:ACTED_IN]-(a) " + "WHERE a.name = 'Sam Worthington' AND avatar.title = 'Avatar' " + "AND other.title <> avatar.title " + "RETURN other.title AS movie, g.name AS genre, path AS path" + ), + ( + "USER INPUT: 'Who co-starred with Keanu Reeves in The Matrix?' " + "QUERY: MATCH path = (k:Actor)-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(co:Actor) " + "WHERE k.name = 'Keanu Reeves' AND m.title = 'Matrix, The' " + "AND co.name <> k.name " + "RETURN co.name AS costar, path AS path" + ), ] @@ -89,7 +123,7 @@ def ensure_openai_extra() -> None: raise SystemExit(OPENAI_EXTRA_INSTALL_HINT) from exc -def build_rag(driver: neo4j.Driver, llm: OpenAILLM) -> GraphRAG: +def build_text2cypher_rag(driver: neo4j.Driver, llm: OpenAILLM) -> GraphRAG: retriever = Text2CypherRetriever( driver, llm=llm, @@ -101,6 +135,36 @@ def build_rag(driver: neo4j.Driver, llm: OpenAILLM) -> GraphRAG: return GraphRAG(retriever=retriever, llm=llm) +def build_vector_cypher_rag( + driver: neo4j.Driver, + llm: OpenAILLM, + embedder: OpenAIEmbeddings, +) -> GraphRAG: + retriever = VectorCypherRetriever( + driver, + index_name=INDEX_NAME, + retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + result_formatter=movies_vector_cypher_explain_formatter, + embedder=embedder, + neo4j_database=DATABASE, + ) + return GraphRAG(retriever=retriever, llm=llm) + + +def build_rag( + driver: neo4j.Driver, + llm: OpenAILLM, + *, + retriever: RetrieverName, + embedder: OpenAIEmbeddings | None = None, +) -> GraphRAG: + if retriever == "vector-cypher": + if embedder is None: + embedder = OpenAIEmbeddings() + return build_vector_cypher_rag(driver, llm, embedder) + return build_text2cypher_rag(driver, llm) + + def format_explain_table(explain: ExplainResult) -> str: lines = [f"Retriever: {explain.trace.retriever}"] if explain.trace.cypher: @@ -146,11 +210,17 @@ def result_to_json(answer: str, explain: ExplainResult | None) -> dict[str, Any] def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--retriever", + choices=("text2cypher", "vector-cypher"), + default="text2cypher", + help="Retriever to use (default: text2cypher)", + ) parser.add_argument( "question", nargs="?", - default=DEFAULT_QUESTION, - help=f"Question to ask (default: {DEFAULT_QUESTION!r})", + default=None, + help="Question to ask (default depends on --retriever)", ) parser.add_argument( "--explain", @@ -164,24 +234,45 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="table", help="Output format (default: table)", ) - return parser.parse_args(argv) + parser.add_argument( + "--top-k", + type=int, + default=3, + help="Number of vector hits for vector-cypher mode (default: 3)", + ) + args = parser.parse_args(argv) + retriever_name: RetrieverName = args.retriever + if args.question is None: + args.question = DEFAULT_QUESTIONS[retriever_name] + return args def main(argv: list[str] | None = None) -> int: args = parse_args(argv) ensure_openai_extra() llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0}) + embedder = OpenAIEmbeddings() if args.retriever == "vector-cypher" else None + retriever_config = ( + {"top_k": args.top_k} if args.retriever == "vector-cypher" else {} + ) with neo4j.GraphDatabase.driver(URI, auth=AUTH) as driver: - rag = build_rag(driver, llm) + rag = build_rag( + driver, + llm, + retriever=args.retriever, + embedder=embedder, + ) result = rag.search( args.question, explain=ExplainConfig() if args.explain else None, + retriever_config=retriever_config, ) if args.format == "json": print(json.dumps(result_to_json(result.answer, result.explain), indent=2)) return 0 + print(f"Retriever: {args.retriever}") print(f"Question: {args.question}") print() print("Answer:") From c34de6e4fa6865863f91cf051728fb1fd7b23d7c Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 13:27:47 +0200 Subject: [PATCH 11/20] Fix union types --- src/neo4j_graphrag/generation/explain.py | 6 ++++-- tests/unit/test_explain.py | 23 +++++++++++++++------- tests/unit/test_explain_recommendations.py | 6 ++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index b3d5580ac..77b0b8295 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -326,10 +326,12 @@ def _node_from_neo4j_graph_node(node: neo4j.graph.Node) -> GraphNodeRef: def _relationship_from_neo4j_graph_rel( relationship: neo4j.graph.Relationship, ) -> GraphRelationshipRef: + start_node = relationship.start_node + end_node = relationship.end_node return GraphRelationshipRef( type=relationship.type, - start_id=relationship.start_node.element_id, - end_id=relationship.end_node.element_id, + start_id=start_node.element_id if start_node is not None else None, + end_id=end_node.element_id if end_node is not None else None, ) diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index 36105593d..a9abd8e58 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -19,6 +19,7 @@ ExplainConfig, ExplainResult, GraphContext, + GraphNodeRef, GraphRelationshipRef, TraceStep, build_explain_result, @@ -148,7 +149,9 @@ def test_graph_from_retriever_parses_graph_and_paths() -> None: assert graph[0].related_nodes[0].properties["name"] == "Zoe Saldana" assert graph[0].relationships[0].type == "ACTED_IN" assert len(graph[0].paths) == 1 - assert graph[0].paths[0][1].type == "ACTED_IN" + path_rel = graph[0].paths[0][1] + assert isinstance(path_rel, GraphRelationshipRef) + assert path_rel.type == "ACTED_IN" def test_graph_from_retriever_returns_none_without_graph_metadata() -> None: @@ -276,12 +279,18 @@ def test_serialize_neo4j_path_from_graph_objects() -> None: serialized = serialize_neo4j_path(path) - assert serialized[0].id == "4:actor" - assert serialized[0].properties["name"] == "Zoe Saldana" - assert serialized[1].type == "ACTED_IN" - assert serialized[1].start_id == "4:actor" - assert serialized[1].end_id == "4:movie" - assert serialized[2].properties["title"] == "Avatar" + actor_ref = serialized[0] + relationship_ref = serialized[1] + movie_ref = serialized[2] + assert isinstance(actor_ref, GraphNodeRef) + assert isinstance(relationship_ref, GraphRelationshipRef) + assert isinstance(movie_ref, GraphNodeRef) + assert actor_ref.id == "4:actor" + assert actor_ref.properties["name"] == "Zoe Saldana" + assert relationship_ref.type == "ACTED_IN" + assert relationship_ref.start_id == "4:actor" + assert relationship_ref.end_id == "4:movie" + assert movie_ref.properties["title"] == "Avatar" def test_node_from_neo4j_graph_node_serializes_temporal_properties() -> None: diff --git a/tests/unit/test_explain_recommendations.py b/tests/unit/test_explain_recommendations.py index 98ec89f41..cc4b3d412 100644 --- a/tests/unit/test_explain_recommendations.py +++ b/tests/unit/test_explain_recommendations.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock import neo4j -from neo4j_graphrag.generation.explain import build_explain_result +from neo4j_graphrag.generation.explain import GraphRelationshipRef, build_explain_result from neo4j_graphrag.generation.explain_recommendations import ( graph_and_paths_from_record, movies_vector_cypher_explain_formatter, @@ -137,5 +137,7 @@ def test_build_explain_result_from_movies_formatter_output() -> None: explain = build_explain_result(result) assert explain.graph is not None - assert explain.graph[0].paths[0][1].type == "ACTED_IN" + path_rel = explain.graph[0].paths[0][1] + assert isinstance(path_rel, GraphRelationshipRef) + assert path_rel.type == "ACTED_IN" assert explain.sources[0].score == 0.91 From a87b12a8338544104e83883e2eb6b8aa4862cdce Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 13:40:11 +0200 Subject: [PATCH 12/20] Move demo helpers out of main lib --- docs/source/user_guide_rag.rst | 11 ++++----- .../graphrag_with_explain.py | 2 +- .../recommendations_explain.py | 23 +++++-------------- pyproject.toml | 1 + src/neo4j_graphrag/generation/__init__.py | 10 ++------ src/neo4j_graphrag/generation/explain.py | 5 ++++ ...ons.py => test_recommendations_explain.py} | 4 ++-- 7 files changed, 22 insertions(+), 34 deletions(-) rename src/neo4j_graphrag/generation/explain_recommendations.py => examples/question_answering/recommendations_explain.py (87%) rename tests/unit/{test_explain_recommendations.py => test_recommendations_explain.py} (98%) diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 51290de65..469d502e9 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1491,19 +1491,18 @@ available on ``result.explain.trace.cypher``. For graph-path provenance from vector similarity hits, use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each retrieved item. -The recommendations demo database has helpers in -``neo4j_graphrag.generation.explain_recommendations`` such as -``movies_vector_cypher_explain_formatter`` and -``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY``. +The recommendations demo ships an example formatter in +``examples/question_answering/recommendations_explain.py`` (``movies_vector_cypher_explain_formatter`` +and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY``). Copy or adapt it for your schema. .. code:: python from neo4j_graphrag.generation import ExplainConfig, GraphRAG - from neo4j_graphrag.generation.explain_recommendations import ( + from neo4j_graphrag.retrievers import VectorCypherRetriever + from recommendations_explain import ( MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, movies_vector_cypher_explain_formatter, ) - from neo4j_graphrag.retrievers import VectorCypherRetriever retriever = VectorCypherRetriever( driver, diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 814482c11..472eeea28 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -36,7 +36,7 @@ GraphRAG, text2cypher_explain_result_formatter, ) -from neo4j_graphrag.generation.explain_recommendations import ( +from recommendations_explain import ( MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, movies_vector_cypher_explain_formatter, ) diff --git a/src/neo4j_graphrag/generation/explain_recommendations.py b/examples/question_answering/recommendations_explain.py similarity index 87% rename from src/neo4j_graphrag/generation/explain_recommendations.py rename to examples/question_answering/recommendations_explain.py index 241311dad..cd1c7abdd 100644 --- a/src/neo4j_graphrag/generation/explain_recommendations.py +++ b/examples/question_answering/recommendations_explain.py @@ -1,18 +1,7 @@ -# 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. -"""Recommendations demo helpers for GraphRAG explainability.""" +"""VectorCypher explain helpers for the public recommendations demo database. + +Used by ``graphrag_with_explain.py``; copy or adapt for your own graph schema. +""" from __future__ import annotations @@ -24,7 +13,7 @@ GraphContext, GraphNodeRef, GraphRelationshipRef, - _node_from_neo4j_graph_node, + node_from_neo4j_graph_node, serialize_paths, vector_cypher_explain_result_formatter, ) @@ -67,7 +56,7 @@ def graph_and_paths_from_record( seed_node: GraphNodeRef | None = None node = record.get(node_key) if isinstance(node, neo4j.graph.Node): - seed_node = _node_from_neo4j_graph_node(node) + seed_node = node_from_neo4j_graph_node(node) else: title = record.get(title_key) plot = record.get(plot_key) diff --git a/pyproject.toml b/pyproject.toml index 0c44ff8d2..6ac6df957 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ packages = ["src/neo4j_graphrag"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["examples/question_answering"] filterwarnings = [ "", ] diff --git a/src/neo4j_graphrag/generation/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 740530149..912a02279 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -11,6 +11,7 @@ format_retrieval_context, graph_context_from_neo4j_record, graph_from_retriever, + node_from_neo4j_graph_node, serialize_neo4j_path, serialize_paths, sources_from_retriever, @@ -18,11 +19,6 @@ trace_from_retriever, vector_cypher_explain_result_formatter, ) -from .explain_recommendations import ( - MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - graph_and_paths_from_record, - movies_vector_cypher_explain_formatter, -) from .graphrag import GraphRAG from .prompts import PromptTemplate, RagTemplate, SchemaExtractionTemplate @@ -34,7 +30,6 @@ "GraphPath", "GraphRelationshipRef", "GraphRAG", - "MOVIES_ACTORS_PATH_RETRIEVAL_QUERY", "PromptTemplate", "RagTemplate", "SchemaExtractionTemplate", @@ -42,10 +37,9 @@ "TraceStep", "build_explain_result", "format_retrieval_context", - "graph_and_paths_from_record", "graph_context_from_neo4j_record", "graph_from_retriever", - "movies_vector_cypher_explain_formatter", + "node_from_neo4j_graph_node", "serialize_neo4j_path", "serialize_paths", "sources_from_retriever", diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 77b0b8295..a80185261 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -323,6 +323,11 @@ def _node_from_neo4j_graph_node(node: neo4j.graph.Node) -> GraphNodeRef: ) +def node_from_neo4j_graph_node(node: neo4j.graph.Node) -> GraphNodeRef: + """Serialize a Neo4j driver node for explainability metadata.""" + return _node_from_neo4j_graph_node(node) + + def _relationship_from_neo4j_graph_rel( relationship: neo4j.graph.Relationship, ) -> GraphRelationshipRef: diff --git a/tests/unit/test_explain_recommendations.py b/tests/unit/test_recommendations_explain.py similarity index 98% rename from tests/unit/test_explain_recommendations.py rename to tests/unit/test_recommendations_explain.py index cc4b3d412..5122aa577 100644 --- a/tests/unit/test_explain_recommendations.py +++ b/tests/unit/test_recommendations_explain.py @@ -16,11 +16,11 @@ import neo4j from neo4j_graphrag.generation.explain import GraphRelationshipRef, build_explain_result -from neo4j_graphrag.generation.explain_recommendations import ( +from neo4j_graphrag.types import RetrieverResult +from recommendations_explain import ( graph_and_paths_from_record, movies_vector_cypher_explain_formatter, ) -from neo4j_graphrag.types import RetrieverResult def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: From 2a162c264ae35e0ca602be75f646bc8e4638ed99 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 13:47:27 +0200 Subject: [PATCH 13/20] Merge helpers with explain demo CLI --- docs/source/user_guide_rag.rst | 8 +- .../graphrag_with_explain.py | 162 ++++++++++++++++- .../recommendations_explain.py | 170 ------------------ tests/unit/test_recommendations_explain.py | 2 +- 4 files changed, 164 insertions(+), 178 deletions(-) delete mode 100644 examples/question_answering/recommendations_explain.py diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 469d502e9..5ddd06247 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1491,15 +1491,15 @@ available on ``result.explain.trace.cypher``. For graph-path provenance from vector similarity hits, use a VectorCypher ``result_formatter`` that populates ``metadata.graph`` on each retrieved item. -The recommendations demo ships an example formatter in -``examples/question_answering/recommendations_explain.py`` (``movies_vector_cypher_explain_formatter`` -and ``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY``). Copy or adapt it for your schema. +See ``examples/question_answering/graphrag_with_explain.py`` for a full example +with ``movies_vector_cypher_explain_formatter`` and +``MOVIES_ACTORS_PATH_RETRIEVAL_QUERY``. Copy or adapt those helpers for your schema. .. code:: python from neo4j_graphrag.generation import ExplainConfig, GraphRAG from neo4j_graphrag.retrievers import VectorCypherRetriever - from recommendations_explain import ( + from graphrag_with_explain import ( MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, movies_vector_cypher_explain_formatter, ) diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 472eeea28..b4b1ff096 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -35,13 +35,18 @@ ExplainResult, GraphRAG, text2cypher_explain_result_formatter, + vector_cypher_explain_result_formatter, ) -from recommendations_explain import ( - MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - movies_vector_cypher_explain_formatter, +from neo4j_graphrag.generation.explain import ( + GraphContext, + GraphNodeRef, + GraphRelationshipRef, + node_from_neo4j_graph_node, + serialize_paths, ) from neo4j_graphrag.llm import OpenAILLM from neo4j_graphrag.retrievers import Text2CypherRetriever, VectorCypherRetriever +from neo4j_graphrag.types import RetrieverResultItem URI = "neo4j+s://demo.neo4jlabs.com" AUTH = ("recommendations", "recommendations") @@ -116,6 +121,157 @@ ] +MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ +MATCH path = (actor:Actor)-[:ACTED_IN]->(node) +WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths +OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) +WITH node, score, actors, actorPaths, + [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, + [path IN collect(directorPath) WHERE path IS NOT NULL] AS directorPaths +RETURN node.title AS movieTitle, + node.plot AS moviePlot, + actors, + directors, + actorPaths + directorPaths AS paths, + score AS similarityScore +""".strip() + + +def graph_and_paths_from_record( + record: neo4j.Record, + *, + node_key: str = "node", + title_key: str = "movieTitle", + plot_key: str = "moviePlot", + actors_key: str = "actors", + directors_key: str = "directors", + paths_key: str = "paths", + movie_labels: list[str] | None = None, + actor_labels: list[str] | None = None, + director_labels: list[str] | None = None, +) -> dict[str, Any]: + """Build metadata.graph from a recommendations VectorCypher retrieval row.""" + movie_label_list = movie_labels or ["Movie"] + actor_label_list = actor_labels or ["Actor"] + director_label_list = director_labels or ["Person"] + + seed_node: GraphNodeRef | None = None + node = record.get(node_key) + if isinstance(node, neo4j.graph.Node): + seed_node = node_from_neo4j_graph_node(node) + else: + title = record.get(title_key) + plot = record.get(plot_key) + if title is not None or plot is not None: + properties: dict[str, Any] = {} + if title is not None: + properties["title"] = title + if plot is not None: + properties["plot"] = plot + seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) + + related_nodes: list[GraphNodeRef] = [] + relationships: list[GraphRelationshipRef] = [] + actors = record.get(actors_key) or [] + if isinstance(actors, list): + for actor_name in actors: + if actor_name is None: + continue + actor_node = GraphNodeRef( + labels=actor_label_list, + properties={"name": str(actor_name)}, + ) + related_nodes.append(actor_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="ACTED_IN", + start_id=None, + end_id=seed_node.id, + ) + ) + + directors = record.get(directors_key) or [] + if isinstance(directors, list): + for director_name in directors: + if director_name is None: + continue + director_node = GraphNodeRef( + labels=director_label_list, + properties={"name": str(director_name)}, + ) + related_nodes.append(director_node) + if seed_node is not None and seed_node.id is not None: + relationships.append( + GraphRelationshipRef( + type="DIRECTED", + start_id=None, + end_id=seed_node.id, + ) + ) + + paths = serialize_paths(record.get(paths_key)) + if not paths and seed_node is not None and related_nodes: + for person_name, rel_type, labels in ( + *( + (name, "ACTED_IN", actor_label_list) + for name in actors + if isinstance(actors, list) and name is not None + ), + *( + (name, "DIRECTED", director_label_list) + for name in directors + if isinstance(directors, list) and name is not None + ), + ): + person_node = GraphNodeRef( + labels=labels, + properties={"name": str(person_name)}, + ) + paths.append( + [ + person_node, + GraphRelationshipRef(type=rel_type), + seed_node, + ] + ) + + return GraphContext( + seed_node=seed_node, + related_nodes=related_nodes, + relationships=relationships, + paths=paths, + ).model_dump(exclude_none=True) + + +def movies_vector_cypher_explain_formatter( + record: neo4j.Record, +) -> RetrieverResultItem: + actors = record.get("actors") or [] + if isinstance(actors, list): + actors_text = ", ".join(str(actor) for actor in actors if actor is not None) + else: + actors_text = str(actors) + directors = record.get("directors") or [] + if isinstance(directors, list): + directors_text = ", ".join( + str(director) for director in directors if director is not None + ) + else: + directors_text = str(directors) + title = record.get("movieTitle") + plot = record.get("moviePlot") + content = ( + f"Movie title: {title}, Plot: {plot}, " + f"Actors: {actors_text}, Directors: {directors_text}" + ) + return vector_cypher_explain_result_formatter( + record, + content=content, + graph_builder=graph_and_paths_from_record, + ) + + def ensure_openai_extra() -> None: try: import openai # noqa: F401 diff --git a/examples/question_answering/recommendations_explain.py b/examples/question_answering/recommendations_explain.py deleted file mode 100644 index cd1c7abdd..000000000 --- a/examples/question_answering/recommendations_explain.py +++ /dev/null @@ -1,170 +0,0 @@ -"""VectorCypher explain helpers for the public recommendations demo database. - -Used by ``graphrag_with_explain.py``; copy or adapt for your own graph schema. -""" - -from __future__ import annotations - -from typing import Any - -import neo4j - -from neo4j_graphrag.generation.explain import ( - GraphContext, - GraphNodeRef, - GraphRelationshipRef, - node_from_neo4j_graph_node, - serialize_paths, - vector_cypher_explain_result_formatter, -) -from neo4j_graphrag.types import RetrieverResultItem - -MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ -MATCH path = (actor:Actor)-[:ACTED_IN]->(node) -WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths -OPTIONAL MATCH directorPath = (director:Person)-[:DIRECTED]->(node) -WITH node, score, actors, actorPaths, - [name IN collect(DISTINCT director.name) WHERE name IS NOT NULL] AS directors, - [path IN collect(directorPath) WHERE path IS NOT NULL] AS directorPaths -RETURN node.title AS movieTitle, - node.plot AS moviePlot, - actors, - directors, - actorPaths + directorPaths AS paths, - score AS similarityScore -""".strip() - - -def graph_and_paths_from_record( - record: neo4j.Record, - *, - node_key: str = "node", - title_key: str = "movieTitle", - plot_key: str = "moviePlot", - actors_key: str = "actors", - directors_key: str = "directors", - paths_key: str = "paths", - movie_labels: list[str] | None = None, - actor_labels: list[str] | None = None, - director_labels: list[str] | None = None, -) -> dict[str, Any]: - """Build a metadata.graph mapping from a recommendations VectorCypher row.""" - movie_label_list = movie_labels or ["Movie"] - actor_label_list = actor_labels or ["Actor"] - director_label_list = director_labels or ["Person"] - - seed_node: GraphNodeRef | None = None - node = record.get(node_key) - if isinstance(node, neo4j.graph.Node): - seed_node = node_from_neo4j_graph_node(node) - else: - title = record.get(title_key) - plot = record.get(plot_key) - if title is not None or plot is not None: - properties: dict[str, Any] = {} - if title is not None: - properties["title"] = title - if plot is not None: - properties["plot"] = plot - seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) - - related_nodes: list[GraphNodeRef] = [] - relationships: list[GraphRelationshipRef] = [] - actors = record.get(actors_key) or [] - if isinstance(actors, list): - for actor_name in actors: - if actor_name is None: - continue - actor_node = GraphNodeRef( - labels=actor_label_list, - properties={"name": str(actor_name)}, - ) - related_nodes.append(actor_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="ACTED_IN", - start_id=None, - end_id=seed_node.id, - ) - ) - - directors = record.get(directors_key) or [] - if isinstance(directors, list): - for director_name in directors: - if director_name is None: - continue - director_node = GraphNodeRef( - labels=director_label_list, - properties={"name": str(director_name)}, - ) - related_nodes.append(director_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="DIRECTED", - start_id=None, - end_id=seed_node.id, - ) - ) - - paths = serialize_paths(record.get(paths_key)) - if not paths and seed_node is not None and related_nodes: - for person_name, rel_type, labels in ( - *( - (name, "ACTED_IN", actor_label_list) - for name in actors - if isinstance(actors, list) and name is not None - ), - *( - (name, "DIRECTED", director_label_list) - for name in directors - if isinstance(directors, list) and name is not None - ), - ): - person_node = GraphNodeRef( - labels=labels, - properties={"name": str(person_name)}, - ) - paths.append( - [ - person_node, - GraphRelationshipRef(type=rel_type), - seed_node, - ] - ) - - return GraphContext( - seed_node=seed_node, - related_nodes=related_nodes, - relationships=relationships, - paths=paths, - ).model_dump(exclude_none=True) - - -def movies_vector_cypher_explain_formatter( - record: neo4j.Record, -) -> RetrieverResultItem: - actors = record.get("actors") or [] - if isinstance(actors, list): - actors_text = ", ".join(str(actor) for actor in actors if actor is not None) - else: - actors_text = str(actors) - directors = record.get("directors") or [] - if isinstance(directors, list): - directors_text = ", ".join( - str(director) for director in directors if director is not None - ) - else: - directors_text = str(directors) - title = record.get("movieTitle") - plot = record.get("moviePlot") - content = ( - f"Movie title: {title}, Plot: {plot}, " - f"Actors: {actors_text}, Directors: {directors_text}" - ) - return vector_cypher_explain_result_formatter( - record, - content=content, - graph_builder=graph_and_paths_from_record, - ) diff --git a/tests/unit/test_recommendations_explain.py b/tests/unit/test_recommendations_explain.py index 5122aa577..452608a79 100644 --- a/tests/unit/test_recommendations_explain.py +++ b/tests/unit/test_recommendations_explain.py @@ -17,7 +17,7 @@ import neo4j from neo4j_graphrag.generation.explain import GraphRelationshipRef, build_explain_result from neo4j_graphrag.types import RetrieverResult -from recommendations_explain import ( +from graphrag_with_explain import ( graph_and_paths_from_record, movies_vector_cypher_explain_formatter, ) From 74cb3c6fe3562345c9f1f11866b6cfbaf356ddcf Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 14:18:18 +0200 Subject: [PATCH 14/20] Remove demo items from lib --- src/neo4j_graphrag/generation/explain.py | 88 ++++++++++++++++-------- 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index a80185261..68e817cb3 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -386,54 +386,88 @@ def _collect_paths_from_record(record: neo4j.Record) -> list[GraphPath]: return paths +def _relationship_degree_in_paths(paths: list[GraphPath]) -> dict[str, int]: + degree: dict[str, int] = {} + for path in paths: + for element in path: + if not isinstance(element, GraphRelationshipRef): + continue + for node_id in (element.start_id, element.end_id): + if node_id is not None: + degree[node_id] = degree.get(node_id, 0) + 1 + return degree + + def _seed_node_from_record_scalars(record: neo4j.Record) -> GraphNodeRef | None: - for key in ("movie", "m", "node"): - value = record.get(key) + for value in record.values(): if isinstance(value, neo4j.graph.Node): - return _node_from_neo4j_graph_node(value) - title = record.get("title") or record.get("movieTitle") - name = record.get("name") - if title is not None: - return GraphNodeRef(labels=["Movie"], properties={"title": str(title)}) - if name is not None: - return GraphNodeRef(labels=["Person"], properties={"name": str(name)}) - return None + return node_from_neo4j_graph_node(value) + + properties: dict[str, Any] = {} + for key in record.keys(): + if key in _GRAPH_RECORD_KEYS or key.endswith("Path"): + continue + value = record.get(key) + if value is None: + continue + if isinstance( + value, (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) + ): + continue + if ( + isinstance(value, list) + and value + and isinstance( + value[0], (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) + ) + ): + continue + properties[key] = _json_safe_value(value) + + if not properties: + return None + return GraphNodeRef(properties=properties) def _graph_elements_from_paths( paths: list[GraphPath], ) -> tuple[GraphNodeRef | None, list[GraphNodeRef], list[GraphRelationshipRef]]: - seed_node: GraphNodeRef | None = None - related_nodes: list[GraphNodeRef] = [] + node_order: list[GraphNodeRef] = [] + seen_node_keys: set[str] = set() relationships: list[GraphRelationshipRef] = [] - seen_node_ids: set[str] = set() seen_rel_keys: set[tuple[str, str | None, str | None]] = set() for path in paths: for element in path: if isinstance(element, GraphNodeRef): node_key = element.id or str(element.properties) - if "Movie" in element.labels: - if seed_node is None: - seed_node = element - continue - if node_key not in seen_node_ids: - related_nodes.append(element) - seen_node_ids.add(node_key) + if node_key not in seen_node_keys: + node_order.append(element) + seen_node_keys.add(node_key) continue rel_key = (element.type, element.start_id, element.end_id) if rel_key not in seen_rel_keys: relationships.append(element) seen_rel_keys.add(rel_key) + seed_node: GraphNodeRef | None = None + if node_order: + degree = _relationship_degree_in_paths(paths) + seed_node = max( + node_order, + key=lambda node: ( + degree.get(node.id or "", 0), + -node_order.index(node), + ), + ) + if seed_node is None: - for path in paths: - for element in path: - if isinstance(element, GraphNodeRef): - seed_node = element - break - if seed_node is not None: - break + related_nodes = node_order + else: + seed_key = seed_node.id or str(seed_node.properties) + related_nodes = [ + node for node in node_order if (node.id or str(node.properties)) != seed_key + ] return seed_node, related_nodes, relationships From 2d8f8cc75cfc2e621ecc6f62640bdefd948d9b4f Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 14:25:54 +0200 Subject: [PATCH 15/20] Remove demo items from lib --- pyproject.toml | 1 - tests/unit/test_recommendations_explain.py | 17 ++++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ac6df957..0c44ff8d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,6 @@ packages = ["src/neo4j_graphrag"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["examples/question_answering"] filterwarnings = [ "", ] diff --git a/tests/unit/test_recommendations_explain.py b/tests/unit/test_recommendations_explain.py index 452608a79..fee02fd55 100644 --- a/tests/unit/test_recommendations_explain.py +++ b/tests/unit/test_recommendations_explain.py @@ -14,12 +14,23 @@ # limitations under the License. from unittest.mock import MagicMock +from pathlib import Path +import importlib +import sys + import neo4j from neo4j_graphrag.generation.explain import GraphRelationshipRef, build_explain_result from neo4j_graphrag.types import RetrieverResult -from graphrag_with_explain import ( - graph_and_paths_from_record, - movies_vector_cypher_explain_formatter, + +EXAMPLES_QA_DIR = ( + Path(__file__).resolve().parents[2] / "examples" / "question_answering" +) +if str(EXAMPLES_QA_DIR) not in sys.path: + sys.path.insert(0, str(EXAMPLES_QA_DIR)) +_demo_module = importlib.import_module("graphrag_with_explain") +graph_and_paths_from_record = _demo_module.graph_and_paths_from_record +movies_vector_cypher_explain_formatter = ( + _demo_module.movies_vector_cypher_explain_formatter ) From 6192937c91c426e9af99610bc0949ca8a73c43dd Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 14:49:51 +0200 Subject: [PATCH 16/20] Reduce complexity --- docs/source/user_guide_rag.rst | 10 +- .../graphrag_with_explain.py | 215 ++++------------- src/neo4j_graphrag/generation/explain.py | 219 +----------------- tests/unit/test_explain.py | 84 +++---- tests/unit/test_recommendations_explain.py | 154 ------------ 5 files changed, 85 insertions(+), 597 deletions(-) delete mode 100644 tests/unit/test_recommendations_explain.py diff --git a/docs/source/user_guide_rag.rst b/docs/source/user_guide_rag.rst index 5ddd06247..c763db2c1 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1457,13 +1457,13 @@ Explainability GraphRAG can attach structured provenance to a search result when ``explain`` is set. The payload includes numbered sources (aligned with LLM context), optional graph -neighborhood and paths from the retriever, and a minimal retriever trace. +paths from the retriever, and a minimal retriever trace. For ask-anything graph questions, pair explain with ``Text2CypherRetriever`` and -``text2cypher_explain_result_formatter``. Return Cypher paths or nodes in the -query results (for example ``RETURN m.title AS title, path AS path``) so -``result.explain.graph`` can be populated. The generated Cypher query is also -available on ``result.explain.trace.cypher``. +``text2cypher_explain_result_formatter``. Return Cypher paths in the query results +(for example ``RETURN m.title AS title, path AS path``) so ``result.explain.graph`` +can be populated. The generated Cypher query is also available on +``result.explain.trace.cypher``. .. code:: python diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index b4b1ff096..acdb93c4b 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -37,13 +37,7 @@ text2cypher_explain_result_formatter, vector_cypher_explain_result_formatter, ) -from neo4j_graphrag.generation.explain import ( - GraphContext, - GraphNodeRef, - GraphRelationshipRef, - node_from_neo4j_graph_node, - serialize_paths, -) +from neo4j_graphrag.generation.explain import GraphContext, serialize_paths from neo4j_graphrag.llm import OpenAILLM from neo4j_graphrag.retrievers import Text2CypherRetriever, VectorCypherRetriever from neo4j_graphrag.types import RetrieverResultItem @@ -120,7 +114,6 @@ ), ] - MOVIES_ACTORS_PATH_RETRIEVAL_QUERY = """ MATCH path = (actor:Actor)-[:ACTED_IN]->(node) WITH node, score, collect(DISTINCT actor.name) AS actors, collect(path) AS actorPaths @@ -137,128 +130,26 @@ """.strip() -def graph_and_paths_from_record( - record: neo4j.Record, - *, - node_key: str = "node", - title_key: str = "movieTitle", - plot_key: str = "moviePlot", - actors_key: str = "actors", - directors_key: str = "directors", - paths_key: str = "paths", - movie_labels: list[str] | None = None, - actor_labels: list[str] | None = None, - director_labels: list[str] | None = None, -) -> dict[str, Any]: - """Build metadata.graph from a recommendations VectorCypher retrieval row.""" - movie_label_list = movie_labels or ["Movie"] - actor_label_list = actor_labels or ["Actor"] - director_label_list = director_labels or ["Person"] - - seed_node: GraphNodeRef | None = None - node = record.get(node_key) - if isinstance(node, neo4j.graph.Node): - seed_node = node_from_neo4j_graph_node(node) - else: - title = record.get(title_key) - plot = record.get(plot_key) - if title is not None or plot is not None: - properties: dict[str, Any] = {} - if title is not None: - properties["title"] = title - if plot is not None: - properties["plot"] = plot - seed_node = GraphNodeRef(labels=movie_label_list, properties=properties) - - related_nodes: list[GraphNodeRef] = [] - relationships: list[GraphRelationshipRef] = [] - actors = record.get(actors_key) or [] - if isinstance(actors, list): - for actor_name in actors: - if actor_name is None: - continue - actor_node = GraphNodeRef( - labels=actor_label_list, - properties={"name": str(actor_name)}, - ) - related_nodes.append(actor_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="ACTED_IN", - start_id=None, - end_id=seed_node.id, - ) - ) - - directors = record.get(directors_key) or [] - if isinstance(directors, list): - for director_name in directors: - if director_name is None: - continue - director_node = GraphNodeRef( - labels=director_label_list, - properties={"name": str(director_name)}, - ) - related_nodes.append(director_node) - if seed_node is not None and seed_node.id is not None: - relationships.append( - GraphRelationshipRef( - type="DIRECTED", - start_id=None, - end_id=seed_node.id, - ) - ) - - paths = serialize_paths(record.get(paths_key)) - if not paths and seed_node is not None and related_nodes: - for person_name, rel_type, labels in ( - *( - (name, "ACTED_IN", actor_label_list) - for name in actors - if isinstance(actors, list) and name is not None - ), - *( - (name, "DIRECTED", director_label_list) - for name in directors - if isinstance(directors, list) and name is not None - ), - ): - person_node = GraphNodeRef( - labels=labels, - properties={"name": str(person_name)}, - ) - paths.append( - [ - person_node, - GraphRelationshipRef(type=rel_type), - seed_node, - ] - ) - - return GraphContext( - seed_node=seed_node, - related_nodes=related_nodes, - relationships=relationships, - paths=paths, - ).model_dump(exclude_none=True) +def graph_paths_from_record(record: neo4j.Record) -> GraphContext | None: + paths = serialize_paths(record.get("paths")) + return GraphContext(paths=paths) if paths else None def movies_vector_cypher_explain_formatter( record: neo4j.Record, ) -> RetrieverResultItem: actors = record.get("actors") or [] - if isinstance(actors, list): - actors_text = ", ".join(str(actor) for actor in actors if actor is not None) - else: - actors_text = str(actors) + actors_text = ( + ", ".join(str(actor) for actor in actors if actor is not None) + if isinstance(actors, list) + else str(actors) + ) directors = record.get("directors") or [] - if isinstance(directors, list): - directors_text = ", ".join( - str(director) for director in directors if director is not None - ) - else: - directors_text = str(directors) + directors_text = ( + ", ".join(str(director) for director in directors if director is not None) + if isinstance(directors, list) + else str(directors) + ) title = record.get("movieTitle") plot = record.get("moviePlot") content = ( @@ -268,7 +159,7 @@ def movies_vector_cypher_explain_formatter( return vector_cypher_explain_result_formatter( record, content=content, - graph_builder=graph_and_paths_from_record, + graph_builder=graph_paths_from_record, ) @@ -279,34 +170,6 @@ def ensure_openai_extra() -> None: raise SystemExit(OPENAI_EXTRA_INSTALL_HINT) from exc -def build_text2cypher_rag(driver: neo4j.Driver, llm: OpenAILLM) -> GraphRAG: - retriever = Text2CypherRetriever( - driver, - llm=llm, - neo4j_schema=RECOMMENDATIONS_NEO4J_SCHEMA, - examples=RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES, - result_formatter=text2cypher_explain_result_formatter, - neo4j_database=DATABASE, - ) - return GraphRAG(retriever=retriever, llm=llm) - - -def build_vector_cypher_rag( - driver: neo4j.Driver, - llm: OpenAILLM, - embedder: OpenAIEmbeddings, -) -> GraphRAG: - retriever = VectorCypherRetriever( - driver, - index_name=INDEX_NAME, - retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - result_formatter=movies_vector_cypher_explain_formatter, - embedder=embedder, - neo4j_database=DATABASE, - ) - return GraphRAG(retriever=retriever, llm=llm) - - def build_rag( driver: neo4j.Driver, llm: OpenAILLM, @@ -315,10 +178,25 @@ def build_rag( embedder: OpenAIEmbeddings | None = None, ) -> GraphRAG: if retriever == "vector-cypher": - if embedder is None: - embedder = OpenAIEmbeddings() - return build_vector_cypher_rag(driver, llm, embedder) - return build_text2cypher_rag(driver, llm) + embedder = embedder or OpenAIEmbeddings() + retriever_impl = VectorCypherRetriever( + driver, + index_name=INDEX_NAME, + retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + result_formatter=movies_vector_cypher_explain_formatter, + embedder=embedder, + neo4j_database=DATABASE, + ) + else: + retriever_impl = Text2CypherRetriever( + driver, + llm=llm, + neo4j_schema=RECOMMENDATIONS_NEO4J_SCHEMA, + examples=RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES, + result_formatter=text2cypher_explain_result_formatter, + neo4j_database=DATABASE, + ) + return GraphRAG(retriever=retriever_impl, llm=llm) def format_explain_table(explain: ExplainResult) -> str: @@ -333,15 +211,11 @@ def format_explain_table(explain: ExplainResult) -> str: lines.append(f" [{source.index}]{score} {source.content}") if explain.graph: - lines.extend(["", "Graph context:"]) + lines.extend(["", "Graph paths:"]) for index, context in enumerate(explain.graph, start=1): + if not context.paths: + continue lines.append(f" Source {index}:") - if context.seed_node is not None: - seed = context.seed_node - label = seed.labels[0] if seed.labels else "node" - name = seed.properties.get("title") or seed.properties.get("name") - seed_text = f"{label}({name})" if name else label - lines.append(f" seed: {seed_text}") for path_index, path in enumerate(context.paths, start=1): parts: list[str] = [] for element in path: @@ -357,13 +231,6 @@ def format_explain_table(explain: ExplainResult) -> str: return "\n".join(lines) -def result_to_json(answer: str, explain: ExplainResult | None) -> dict[str, Any]: - payload: dict[str, Any] = {"answer": answer} - if explain is not None: - payload["explain"] = explain.model_dump(mode="json") - return payload - - def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -397,9 +264,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="Number of vector hits for vector-cypher mode (default: 3)", ) args = parser.parse_args(argv) - retriever_name: RetrieverName = args.retriever if args.question is None: - args.question = DEFAULT_QUESTIONS[retriever_name] + args.question = DEFAULT_QUESTIONS[args.retriever] return args @@ -425,7 +291,10 @@ def main(argv: list[str] | None = None) -> int: ) if args.format == "json": - print(json.dumps(result_to_json(result.answer, result.explain), indent=2)) + payload: dict[str, Any] = {"answer": result.answer} + if result.explain is not None: + payload["explain"] = result.explain.model_dump(mode="json") + print(json.dumps(payload, indent=2)) return 0 print(f"Retriever: {args.retriever}") diff --git a/src/neo4j_graphrag/generation/explain.py b/src/neo4j_graphrag/generation/explain.py index 68e817cb3..10213d56c 100644 --- a/src/neo4j_graphrag/generation/explain.py +++ b/src/neo4j_graphrag/generation/explain.py @@ -49,11 +49,8 @@ class GraphRelationshipRef(BaseModel): class GraphContext(BaseModel): - """Graph neighborhood for a single retrieved source.""" + """Graph paths for a single retrieved source.""" - seed_node: GraphNodeRef | None = None - related_nodes: list[GraphNodeRef] = Field(default_factory=list) - relationships: list[GraphRelationshipRef] = Field(default_factory=list) paths: list[GraphPath] = Field(default_factory=list) @@ -199,105 +196,14 @@ def _graph_context_from_item_metadata( graph_data = metadata.get("graph") if isinstance(graph_data, GraphContext): return graph_data - if isinstance(graph_data, dict): - return _graph_context_from_dict(graph_data, metadata.get("paths")) paths_data = metadata.get("paths") if paths_data: - return GraphContext(paths=_parse_paths(paths_data)) + paths = serialize_paths(paths_data) + return GraphContext(paths=paths) if paths else None return None -def _graph_context_from_dict( - data: dict[str, Any], - extra_paths: Any = None, -) -> GraphContext: - seed = data.get("seed_node") - paths_data = data.get("paths", extra_paths) - return GraphContext( - seed_node=_parse_node_ref(seed) if seed else None, - related_nodes=[ - _parse_node_ref(node) - for node in data.get("related_nodes", []) - if node is not None - ], - relationships=[ - _parse_relationship_ref(rel) - for rel in data.get("relationships", []) - if rel is not None - ], - paths=_parse_paths(paths_data), - ) - - -def _parse_paths(paths_data: Any) -> list[GraphPath]: - if not paths_data: - return [] - if not isinstance(paths_data, list): - return [] - paths: list[GraphPath] = [] - for path in paths_data: - if not isinstance(path, list): - continue - elements = [_parse_path_element(element) for element in path] - paths.append(elements) - return paths - - -def _parse_path_element(element: Any) -> GraphPathElement: - if isinstance(element, GraphNodeRef): - return element - if isinstance(element, GraphRelationshipRef): - return element - if not isinstance(element, dict): - raise ValueError("path element must be a mapping or graph ref model") - if _is_relationship_mapping(element): - return _parse_relationship_ref(element) - return _parse_node_ref(element) - - -def _is_relationship_mapping(data: dict[str, Any]) -> bool: - return "type" in data and "labels" not in data and "properties" not in data - - -def _parse_node_ref(data: Any) -> GraphNodeRef: - if isinstance(data, GraphNodeRef): - return data - if not isinstance(data, dict): - raise ValueError("node reference must be a mapping or GraphNodeRef") - labels = data.get("labels", []) - if not isinstance(labels, list): - labels = [str(labels)] - properties = data.get("properties", {}) - if not isinstance(properties, dict): - properties = {} - node_id = data.get("id") - return GraphNodeRef( - id=str(node_id) if node_id is not None else None, - labels=labels, - properties=properties, - ) - - -def _parse_relationship_ref(data: Any) -> GraphRelationshipRef: - if isinstance(data, GraphRelationshipRef): - return data - if not isinstance(data, dict): - raise ValueError( - "relationship reference must be a mapping or GraphRelationshipRef" - ) - rel_type = data.get("type") - if not rel_type: - raise ValueError("relationship reference requires a type") - start_id = data.get("start_id") - end_id = data.get("end_id") - return GraphRelationshipRef( - type=str(rel_type), - start_id=str(start_id) if start_id is not None else None, - end_id=str(end_id) if end_id is not None else None, - ) - - def _json_safe_value(value: Any) -> Any: if isinstance(value, dict): return {str(key): _json_safe_value(item) for key, item in value.items()} @@ -352,19 +258,14 @@ def serialize_neo4j_path(path: neo4j.graph.Path) -> GraphPath: def serialize_paths(paths_value: Any) -> list[GraphPath]: + if isinstance(paths_value, neo4j.graph.Path): + return [serialize_neo4j_path(paths_value)] if not paths_value or not isinstance(paths_value, list): - if isinstance(paths_value, neo4j.graph.Path): - return [serialize_neo4j_path(paths_value)] return [] paths: list[GraphPath] = [] for path in paths_value: if isinstance(path, neo4j.graph.Path): paths.append(serialize_neo4j_path(path)) - continue - if isinstance(path, list): - parsed = _parse_paths([path]) - if parsed: - paths.append(parsed[0]) return paths @@ -386,110 +287,12 @@ def _collect_paths_from_record(record: neo4j.Record) -> list[GraphPath]: return paths -def _relationship_degree_in_paths(paths: list[GraphPath]) -> dict[str, int]: - degree: dict[str, int] = {} - for path in paths: - for element in path: - if not isinstance(element, GraphRelationshipRef): - continue - for node_id in (element.start_id, element.end_id): - if node_id is not None: - degree[node_id] = degree.get(node_id, 0) + 1 - return degree - - -def _seed_node_from_record_scalars(record: neo4j.Record) -> GraphNodeRef | None: - for value in record.values(): - if isinstance(value, neo4j.graph.Node): - return node_from_neo4j_graph_node(value) - - properties: dict[str, Any] = {} - for key in record.keys(): - if key in _GRAPH_RECORD_KEYS or key.endswith("Path"): - continue - value = record.get(key) - if value is None: - continue - if isinstance( - value, (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) - ): - continue - if ( - isinstance(value, list) - and value - and isinstance( - value[0], (neo4j.graph.Path, neo4j.graph.Node, neo4j.graph.Relationship) - ) - ): - continue - properties[key] = _json_safe_value(value) - - if not properties: - return None - return GraphNodeRef(properties=properties) - - -def _graph_elements_from_paths( - paths: list[GraphPath], -) -> tuple[GraphNodeRef | None, list[GraphNodeRef], list[GraphRelationshipRef]]: - node_order: list[GraphNodeRef] = [] - seen_node_keys: set[str] = set() - relationships: list[GraphRelationshipRef] = [] - seen_rel_keys: set[tuple[str, str | None, str | None]] = set() - - for path in paths: - for element in path: - if isinstance(element, GraphNodeRef): - node_key = element.id or str(element.properties) - if node_key not in seen_node_keys: - node_order.append(element) - seen_node_keys.add(node_key) - continue - rel_key = (element.type, element.start_id, element.end_id) - if rel_key not in seen_rel_keys: - relationships.append(element) - seen_rel_keys.add(rel_key) - - seed_node: GraphNodeRef | None = None - if node_order: - degree = _relationship_degree_in_paths(paths) - seed_node = max( - node_order, - key=lambda node: ( - degree.get(node.id or "", 0), - -node_order.index(node), - ), - ) - - if seed_node is None: - related_nodes = node_order - else: - seed_key = seed_node.id or str(seed_node.properties) - related_nodes = [ - node for node in node_order if (node.id or str(node.properties)) != seed_key - ] - - return seed_node, related_nodes, relationships - - -def graph_context_from_neo4j_record(record: neo4j.Record) -> dict[str, Any] | None: - """Build metadata.graph from Neo4j paths or nodes returned by Text2Cypher.""" +def graph_context_from_neo4j_record(record: neo4j.Record) -> GraphContext | None: + """Build graph context from Neo4j paths returned by Text2Cypher.""" paths = _collect_paths_from_record(record) - if paths: - seed_node, related_nodes, relationships = _graph_elements_from_paths(paths) - if seed_node is None: - seed_node = _seed_node_from_record_scalars(record) - return GraphContext( - seed_node=seed_node, - related_nodes=related_nodes, - relationships=relationships, - paths=paths, - ).model_dump(exclude_none=True) - - seed_node = _seed_node_from_record_scalars(record) - if seed_node is None: + if not paths: return None - return GraphContext(seed_node=seed_node).model_dump(exclude_none=True) + return GraphContext(paths=paths) def vector_cypher_explain_result_formatter( @@ -497,7 +300,7 @@ def vector_cypher_explain_result_formatter( *, content: str, score_key: str = "similarityScore", - graph_builder: Callable[[neo4j.Record], dict[str, Any] | None] | None = None, + graph_builder: Callable[[neo4j.Record], GraphContext | None] | None = None, ) -> RetrieverResultItem: from neo4j_graphrag.types import RetrieverResultItem @@ -515,7 +318,7 @@ def vector_cypher_explain_result_formatter( def text2cypher_explain_result_formatter( record: neo4j.Record, *, - graph_builder: Callable[[neo4j.Record], dict[str, Any] | None] | None = None, + graph_builder: Callable[[neo4j.Record], GraphContext | None] | None = None, ) -> RetrieverResultItem: from neo4j_graphrag.types import RetrieverResultItem diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py index a9abd8e58..57ff05374 100644 --- a/tests/unit/test_explain.py +++ b/tests/unit/test_explain.py @@ -96,46 +96,23 @@ def test_trace_from_retriever_defaults_to_unknown() -> None: assert trace.retriever == "unknown" -def test_graph_from_retriever_parses_graph_and_paths() -> None: +def test_graph_from_retriever_reads_graph_context_objects() -> None: + actor = GraphNodeRef(labels=["Actor"], properties={"name": "Zoe Saldana"}) + movie = GraphNodeRef( + id="4:movie", + labels=["Movie"], + properties={"title": "Avatar"}, + ) + acted_in = GraphRelationshipRef( + type="ACTED_IN", start_id="4:actor", end_id="4:movie" + ) + graph_context = GraphContext(paths=[[actor, acted_in, movie]]) + result = RetrieverResult( items=[ RetrieverResultItem( content="Movie: Avatar", - metadata={ - "graph": { - "seed_node": { - "id": "4:movie", - "labels": ["Movie"], - "properties": {"title": "Avatar"}, - }, - "related_nodes": [ - { - "labels": ["Actor"], - "properties": {"name": "Zoe Saldana"}, - } - ], - "relationships": [ - { - "type": "ACTED_IN", - "start_id": "4:actor", - "end_id": "4:movie", - } - ], - "paths": [ - [ - { - "labels": ["Actor"], - "properties": {"name": "Zoe Saldana"}, - }, - {"type": "ACTED_IN"}, - { - "labels": ["Movie"], - "properties": {"title": "Avatar"}, - }, - ] - ], - } - }, + metadata={"graph": graph_context}, ) ] ) @@ -144,11 +121,7 @@ def test_graph_from_retriever_parses_graph_and_paths() -> None: assert graph is not None assert len(graph) == 1 - assert graph[0].seed_node is not None - assert graph[0].seed_node.properties["title"] == "Avatar" - assert graph[0].related_nodes[0].properties["name"] == "Zoe Saldana" - assert graph[0].relationships[0].type == "ACTED_IN" - assert len(graph[0].paths) == 1 + assert graph[0].paths == graph_context.paths path_rel = graph[0].paths[0][1] assert isinstance(path_rel, GraphRelationshipRef) assert path_rel.type == "ACTED_IN" @@ -163,19 +136,13 @@ def test_graph_from_retriever_returns_none_without_graph_metadata() -> None: def test_build_explain_result_combines_sources_trace_and_graph() -> None: + movie = GraphNodeRef(labels=["Movie"], properties={"title": "Avatar"}) + graph_context = GraphContext(paths=[[movie]]) result = RetrieverResult( items=[ RetrieverResultItem( content="Movie: Avatar", - metadata={ - "score": 0.89, - "graph": { - "seed_node": { - "labels": ["Movie"], - "properties": {"title": "Avatar"}, - } - }, - }, + metadata={"score": 0.89, "graph": graph_context}, ) ], metadata={"__retriever": "VectorCypherRetriever"}, @@ -186,7 +153,9 @@ def test_build_explain_result_combines_sources_trace_and_graph() -> None: assert explain.trace.retriever == "VectorCypherRetriever" assert len(explain.sources) == 1 assert explain.graph is not None - assert explain.graph[0].seed_node is not None + first_node = explain.graph[0].paths[0][0] + assert isinstance(first_node, GraphNodeRef) + assert first_node.properties["title"] == "Avatar" def test_format_retrieval_context_adds_source_indexes() -> None: @@ -314,7 +283,6 @@ def test_node_from_neo4j_graph_node_serializes_temporal_properties() -> None: trace=TraceStep(retriever="VectorCypherRetriever"), graph=[ GraphContext( - seed_node=node_ref, paths=[[node_ref, GraphRelationshipRef(type="ACTED_IN"), node_ref]], ) ], @@ -330,9 +298,7 @@ def test_text2cypher_explain_result_formatter_formats_record() -> None: assert item.content == "title: One Flew Over the Cuckoo's Nest" assert item.metadata is not None - assert item.metadata["graph"]["seed_node"]["properties"]["title"] == ( - "One Flew Over the Cuckoo's Nest" - ) + assert item.metadata.get("graph") is None def test_text2cypher_explain_result_formatter_includes_graph_paths() -> None: @@ -367,5 +333,9 @@ def test_text2cypher_explain_result_formatter_includes_graph_paths() -> None: assert item.content == "title: Fargo" assert item.metadata is not None - assert len(item.metadata["graph"]["paths"]) == 1 - assert item.metadata["graph"]["paths"][0][1]["type"] == "DIRECTED" + graph = item.metadata["graph"] + assert isinstance(graph, GraphContext) + assert len(graph.paths) == 1 + path_rel = graph.paths[0][1] + assert isinstance(path_rel, GraphRelationshipRef) + assert path_rel.type == "DIRECTED" diff --git a/tests/unit/test_recommendations_explain.py b/tests/unit/test_recommendations_explain.py deleted file mode 100644 index fee02fd55..000000000 --- a/tests/unit/test_recommendations_explain.py +++ /dev/null @@ -1,154 +0,0 @@ -# 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 - -from pathlib import Path -import importlib -import sys - -import neo4j -from neo4j_graphrag.generation.explain import GraphRelationshipRef, build_explain_result -from neo4j_graphrag.types import RetrieverResult - -EXAMPLES_QA_DIR = ( - Path(__file__).resolve().parents[2] / "examples" / "question_answering" -) -if str(EXAMPLES_QA_DIR) not in sys.path: - sys.path.insert(0, str(EXAMPLES_QA_DIR)) -_demo_module = importlib.import_module("graphrag_with_explain") -graph_and_paths_from_record = _demo_module.graph_and_paths_from_record -movies_vector_cypher_explain_formatter = ( - _demo_module.movies_vector_cypher_explain_formatter -) - - -def test_graph_and_paths_from_record_builds_seed_actors_and_directors() -> None: - record = neo4j.Record( - { - "movieTitle": "One Flew Over the Cuckoo's Nest", - "moviePlot": "A criminal pleads insanity.", - "actors": ["Michael Berryman"], - "directors": ["Milos Forman"], - "paths": None, - } - ) - - graph = graph_and_paths_from_record(record) - - assert ( - graph["seed_node"]["properties"]["title"] == "One Flew Over the Cuckoo's Nest" - ) - assert len(graph["related_nodes"]) == 2 - assert len(graph["paths"]) == 2 - assert graph["paths"][0][1]["type"] == "ACTED_IN" - assert graph["paths"][1][1]["type"] == "DIRECTED" - - -def test_graph_and_paths_from_record_builds_seed_and_actors() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana", "Sam Worthington"], - "paths": None, - } - ) - - graph = graph_and_paths_from_record(record) - - assert graph["seed_node"]["properties"]["title"] == "Avatar" - assert len(graph["related_nodes"]) == 2 - assert graph["related_nodes"][0]["properties"]["name"] == "Zoe Saldana" - assert len(graph["paths"]) == 2 - assert graph["paths"][0][1]["type"] == "ACTED_IN" - - -def test_graph_and_paths_from_record_uses_neo4j_paths() -> None: - actor = MagicMock(spec=neo4j.graph.Node) - actor.element_id = "4:actor" - actor.labels = ["Actor"] - actor.items.return_value = {"name": "Zoe Saldana"}.items() - movie = MagicMock(spec=neo4j.graph.Node) - movie.element_id = "4:movie" - movie.labels = ["Movie"] - movie.items.return_value = {"title": "Avatar"}.items() - relationship = MagicMock(spec=neo4j.graph.Relationship) - relationship.type = "ACTED_IN" - relationship.start_node = actor - relationship.end_node = movie - path = MagicMock(spec=neo4j.graph.Path) - path.nodes = [actor, movie] - path.relationships = [relationship] - record = neo4j.Record( - { - "node": movie, - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "paths": [path], - } - ) - - graph = graph_and_paths_from_record(record) - - assert graph["seed_node"]["id"] == "4:movie" - assert len(graph["paths"]) == 1 - assert graph["paths"][0][0]["id"] == "4:actor" - assert graph["paths"][0][1]["start_id"] == "4:actor" - - -def test_movies_vector_cypher_explain_formatter_attaches_graph() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "directors": ["James Cameron"], - "similarityScore": 0.91, - "paths": None, - } - ) - - item = movies_vector_cypher_explain_formatter(record) - - assert "Avatar" in item.content - assert "James Cameron" in item.content - assert item.metadata is not None - assert item.metadata["score"] == 0.91 - assert item.metadata["graph"]["seed_node"]["properties"]["title"] == "Avatar" - - -def test_build_explain_result_from_movies_formatter_output() -> None: - record = neo4j.Record( - { - "movieTitle": "Avatar", - "moviePlot": "A marine on an alien planet.", - "actors": ["Zoe Saldana"], - "similarityScore": 0.91, - "paths": None, - } - ) - result = RetrieverResult( - items=[movies_vector_cypher_explain_formatter(record)], - metadata={"__retriever": "VectorCypherRetriever"}, - ) - - explain = build_explain_result(result) - - assert explain.graph is not None - path_rel = explain.graph[0].paths[0][1] - assert isinstance(path_rel, GraphRelationshipRef) - assert path_rel.type == "ACTED_IN" - assert explain.sources[0].score == 0.91 From e28ac636c7a327dae4e8c24db43a8905b4ac0ae3 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 15:07:58 +0200 Subject: [PATCH 17/20] Fix assignment --- .../graphrag_with_explain.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index acdb93c4b..cda21d65d 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -179,24 +179,28 @@ def build_rag( ) -> GraphRAG: if retriever == "vector-cypher": embedder = embedder or OpenAIEmbeddings() - retriever_impl = VectorCypherRetriever( - driver, - index_name=INDEX_NAME, - retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, - result_formatter=movies_vector_cypher_explain_formatter, - embedder=embedder, - neo4j_database=DATABASE, + return GraphRAG( + retriever=VectorCypherRetriever( + driver, + index_name=INDEX_NAME, + retrieval_query=MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + result_formatter=movies_vector_cypher_explain_formatter, + embedder=embedder, + neo4j_database=DATABASE, + ), + llm=llm, ) - else: - retriever_impl = Text2CypherRetriever( + return GraphRAG( + retriever=Text2CypherRetriever( driver, llm=llm, neo4j_schema=RECOMMENDATIONS_NEO4J_SCHEMA, examples=RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES, result_formatter=text2cypher_explain_result_formatter, neo4j_database=DATABASE, - ) - return GraphRAG(retriever=retriever_impl, llm=llm) + ), + llm=llm, + ) def format_explain_table(explain: ExplainResult) -> str: From dfad414dc72101a5f2e50cdc8fec632c9c22fd9b Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Tue, 16 Jun 2026 15:16:31 +0200 Subject: [PATCH 18/20] Filter long JSON fields on CLI output --- .../graphrag_with_explain.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index cda21d65d..46cf5deed 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -26,7 +26,7 @@ import argparse import json import sys -from typing import Any, Literal +from typing import Any, Literal, cast import neo4j from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings @@ -235,6 +235,26 @@ def format_explain_table(explain: ExplainResult) -> str: return "\n".join(lines) +def _strip_embedding_properties(value: Any) -> Any: + """Drop vector properties from JSON output (recommendations Movie nodes).""" + if isinstance(value, dict): + return { + key: _strip_embedding_properties(item) + for key, item in value.items() + if not key.endswith("Embedding") + } + if isinstance(value, list): + return [_strip_embedding_properties(item) for item in value] + return value + + +def explain_to_json(explain: ExplainResult) -> dict[str, Any]: + return cast( + dict[str, Any], + _strip_embedding_properties(explain.model_dump(mode="json")), + ) + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -297,7 +317,7 @@ def main(argv: list[str] | None = None) -> int: if args.format == "json": payload: dict[str, Any] = {"answer": result.answer} if result.explain is not None: - payload["explain"] = result.explain.model_dump(mode="json") + payload["explain"] = explain_to_json(result.explain) print(json.dumps(payload, indent=2)) return 0 From 10567c843dac4cc4051ae1b3594ff1549df13828 Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Thu, 25 Jun 2026 10:11:30 +0200 Subject: [PATCH 19/20] Add narrative explanation --- .../graphrag_with_explain.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 46cf5deed..63ef953ea 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -19,6 +19,8 @@ "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?" uv run examples/question_answering/graphrag_with_explain.py --retriever vector-cypher \\ "Find movies about a marine on an alien planet and list the cast and director." + uv run examples/question_answering/graphrag_with_explain.py --explain-narrative \\ + "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?" """ from __future__ import annotations @@ -60,6 +62,15 @@ "Install it with: uv sync --extra openai" ) +NARRATIVE_SYSTEM_INSTRUCTION = ( + "You summarize how a GraphRAG answer was produced from retrieval provenance. " + "Use only the question, answer, and explain payload provided. " + "Describe how sources support the answer, and how graph " + "paths relate when present. Cite sources inline as [1], [2], etc. " + "Do not invent facts, nodes, or paths not present in the explain payload. " + "Keep the summary to 1-2 short sentences, the bare essentials." +) + RECOMMENDATIONS_NEO4J_SCHEMA = """ Node properties: Actor {name: STRING} @@ -255,6 +266,32 @@ def explain_to_json(explain: ExplainResult) -> dict[str, Any]: ) +def build_narrative_prompt( + question: str, + answer: str, + explain: ExplainResult, +) -> str: + explain_payload = json.dumps(explain_to_json(explain), indent=2) + return ( + f"Question:\n{question}\n\n" + f"Answer:\n{answer}\n\n" + f"Explain payload:\n{explain_payload}" + ) + + +def generate_explain_narrative( + llm: OpenAILLM, + question: str, + answer: str, + explain: ExplainResult, +) -> str: + response = llm.invoke( + input=build_narrative_prompt(question, answer, explain), + system_instruction=NARRATIVE_SYSTEM_INSTRUCTION, + ) + return response.content or "" + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -287,7 +324,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=3, help="Number of vector hits for vector-cypher mode (default: 3)", ) + parser.add_argument( + "--explain-narrative", + action=argparse.BooleanOptionalAction, + default=False, + help=( + "Add a second LLM pass that summarizes how the answer follows from " + "explain provenance (demo only; requires --explain)" + ), + ) args = parser.parse_args(argv) + if args.explain_narrative and not args.explain: + parser.error("--explain-narrative requires --explain") if args.question is None: args.question = DEFAULT_QUESTIONS[args.retriever] return args @@ -314,8 +362,19 @@ def main(argv: list[str] | None = None) -> int: retriever_config=retriever_config, ) + narrative: str | None = None + if args.explain_narrative and result.explain is not None: + narrative = generate_explain_narrative( + llm, + args.question, + result.answer, + result.explain, + ) + if args.format == "json": payload: dict[str, Any] = {"answer": result.answer} + if narrative is not None: + payload["narrative"] = narrative if result.explain is not None: payload["explain"] = explain_to_json(result.explain) print(json.dumps(payload, indent=2)) @@ -326,6 +385,10 @@ def main(argv: list[str] | None = None) -> int: print() print("Answer:") print(result.answer) + if narrative is not None: + print() + print("Narrative (AI summary, not provenance):") + print(narrative) if result.explain is not None: print() print(format_explain_table(result.explain)) From 3d73e326b48534b8d33eadc74f6a731efd81e17d Mon Sep 17 00:00:00 2001 From: Zeljko Agic Date: Thu, 25 Jun 2026 10:14:49 +0200 Subject: [PATCH 20/20] Remove narrative explanation --- .../graphrag_with_explain.py | 63 ------------------- uv.lock | 2 +- 2 files changed, 1 insertion(+), 64 deletions(-) diff --git a/examples/question_answering/graphrag_with_explain.py b/examples/question_answering/graphrag_with_explain.py index 63ef953ea..46cf5deed 100644 --- a/examples/question_answering/graphrag_with_explain.py +++ b/examples/question_answering/graphrag_with_explain.py @@ -19,8 +19,6 @@ "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?" uv run examples/question_answering/graphrag_with_explain.py --retriever vector-cypher \\ "Find movies about a marine on an alien planet and list the cast and director." - uv run examples/question_answering/graphrag_with_explain.py --explain-narrative \\ - "Which movies connect Tom Hanks and Kevin Bacon through Ron Howard?" """ from __future__ import annotations @@ -62,15 +60,6 @@ "Install it with: uv sync --extra openai" ) -NARRATIVE_SYSTEM_INSTRUCTION = ( - "You summarize how a GraphRAG answer was produced from retrieval provenance. " - "Use only the question, answer, and explain payload provided. " - "Describe how sources support the answer, and how graph " - "paths relate when present. Cite sources inline as [1], [2], etc. " - "Do not invent facts, nodes, or paths not present in the explain payload. " - "Keep the summary to 1-2 short sentences, the bare essentials." -) - RECOMMENDATIONS_NEO4J_SCHEMA = """ Node properties: Actor {name: STRING} @@ -266,32 +255,6 @@ def explain_to_json(explain: ExplainResult) -> dict[str, Any]: ) -def build_narrative_prompt( - question: str, - answer: str, - explain: ExplainResult, -) -> str: - explain_payload = json.dumps(explain_to_json(explain), indent=2) - return ( - f"Question:\n{question}\n\n" - f"Answer:\n{answer}\n\n" - f"Explain payload:\n{explain_payload}" - ) - - -def generate_explain_narrative( - llm: OpenAILLM, - question: str, - answer: str, - explain: ExplainResult, -) -> str: - response = llm.invoke( - input=build_narrative_prompt(question, answer, explain), - system_instruction=NARRATIVE_SYSTEM_INSTRUCTION, - ) - return response.content or "" - - def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -324,18 +287,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=3, help="Number of vector hits for vector-cypher mode (default: 3)", ) - parser.add_argument( - "--explain-narrative", - action=argparse.BooleanOptionalAction, - default=False, - help=( - "Add a second LLM pass that summarizes how the answer follows from " - "explain provenance (demo only; requires --explain)" - ), - ) args = parser.parse_args(argv) - if args.explain_narrative and not args.explain: - parser.error("--explain-narrative requires --explain") if args.question is None: args.question = DEFAULT_QUESTIONS[args.retriever] return args @@ -362,19 +314,8 @@ def main(argv: list[str] | None = None) -> int: retriever_config=retriever_config, ) - narrative: str | None = None - if args.explain_narrative and result.explain is not None: - narrative = generate_explain_narrative( - llm, - args.question, - result.answer, - result.explain, - ) - if args.format == "json": payload: dict[str, Any] = {"answer": result.answer} - if narrative is not None: - payload["narrative"] = narrative if result.explain is not None: payload["explain"] = explain_to_json(result.explain) print(json.dumps(payload, indent=2)) @@ -385,10 +326,6 @@ def main(argv: list[str] | None = None) -> int: print() print("Answer:") print(result.answer) - if narrative is not None: - print() - print("Narrative (AI summary, not provenance):") - print(narrative) if result.explain is not None: print() print(format_explain_table(result.explain)) diff --git a/uv.lock b/uv.lock index 7bb7de04e..8f72a06c6 100644 --- a/uv.lock +++ b/uv.lock @@ -2974,7 +2974,7 @@ wheels = [ [[package]] name = "neo4j-graphrag" -version = "1.17.0" +version = "1.18.0" source = { editable = "." } dependencies = [ { name = "fsspec" },