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..c763db2c1 100644 --- a/docs/source/user_guide_rag.rst +++ b/docs/source/user_guide_rag.rst @@ -1452,6 +1452,86 @@ 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 +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 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 + + 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. +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 graphrag_with_explain import ( + MOVIES_ACTORS_PATH_RETRIEVAL_QUERY, + movies_vector_cypher_explain_formatter, + ) + + 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`` 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:: + + uv sync --extra openai + + ************** 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..46cf5deed --- /dev/null +++ b/examples/question_answering/graphrag_with_explain.py @@ -0,0 +1,336 @@ +"""GraphRAG with optional explainability output. + +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:: + + 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 + uv run examples/question_answering/graphrag_with_explain.py --format json \\ + "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 + +import argparse +import json +import sys +from typing import Any, Literal, cast + +import neo4j +from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings +from neo4j_graphrag.generation import ( + ExplainConfig, + ExplainResult, + GraphRAG, + text2cypher_explain_result_formatter, + vector_cypher_explain_result_formatter, +) +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 + +URI = "neo4j+s://demo.neo4jlabs.com" +AUTH = ("recommendations", "recommendations") +DATABASE = "recommendations" +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" +) + +RECOMMENDATIONS_NEO4J_SCHEMA = """ +Node properties: +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 {} +RATED {rating: FLOAT, timestamp: INTEGER} +The relationships: +(:Actor)-[:ACTED_IN]->(:Movie) +(:Person)-[:ACTED_IN]->(:Movie) +(:Person)-[:DIRECTED]->(:Movie) +(:Director)-[:DIRECTED]->(:Movie) +(:User)-[:RATED]->(:Movie) +(:Movie)-[:IN_GENRE]->(:Genre) +""".strip() + +RECOMMENDATIONS_TEXT2CYPHER_EXAMPLES = [ + ( + "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" + ), +] + +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_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 [] + 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 [] + 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 = ( + 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_paths_from_record, + ) + + +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, + llm: OpenAILLM, + *, + retriever: RetrieverName, + embedder: OpenAIEmbeddings | None = None, +) -> GraphRAG: + if retriever == "vector-cypher": + embedder = embedder or OpenAIEmbeddings() + 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, + ) + 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, + ), + llm=llm, + ) + + +def format_explain_table(explain: ExplainResult) -> str: + 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 paths:"]) + for index, context in enumerate(explain.graph, start=1): + if not context.paths: + continue + lines.append(f" Source {index}:") + 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) + + +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( + "--retriever", + choices=("text2cypher", "vector-cypher"), + default="text2cypher", + help="Retriever to use (default: text2cypher)", + ) + parser.add_argument( + "question", + nargs="?", + default=None, + help="Question to ask (default depends on --retriever)", + ) + 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 vector hits for vector-cypher mode (default: 3)", + ) + args = parser.parse_args(argv) + if args.question is None: + args.question = DEFAULT_QUESTIONS[args.retriever] + 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, + 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": + payload: dict[str, Any] = {"answer": result.answer} + if result.explain is not None: + payload["explain"] = explain_to_json(result.explain) + print(json.dumps(payload, indent=2)) + return 0 + + print(f"Retriever: {args.retriever}") + 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()) diff --git a/pyproject.toml b/pyproject.toml index 0cfc6fcda..cac556603 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/__init__.py b/src/neo4j_graphrag/generation/__init__.py index 816fe327a..912a02279 100644 --- a/src/neo4j_graphrag/generation/__init__.py +++ b/src/neo4j_graphrag/generation/__init__.py @@ -1,4 +1,49 @@ +from .explain import ( + ExplainConfig, + ExplainResult, + GraphContext, + GraphNodeRef, + GraphPath, + GraphRelationshipRef, + SourceRef, + TraceStep, + build_explain_result, + format_retrieval_context, + graph_context_from_neo4j_record, + graph_from_retriever, + node_from_neo4j_graph_node, + serialize_neo4j_path, + serialize_paths, + sources_from_retriever, + text2cypher_explain_result_formatter, + trace_from_retriever, + vector_cypher_explain_result_formatter, +) 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", + "build_explain_result", + "format_retrieval_context", + "graph_context_from_neo4j_record", + "graph_from_retriever", + "node_from_neo4j_graph_node", + "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 new file mode 100644 index 000000000..10213d56c --- /dev/null +++ b/src/neo4j_graphrag/generation/explain.py @@ -0,0 +1,354 @@ +# 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 TYPE_CHECKING, Any, Callable, Union + +import neo4j +from pydantic import BaseModel, Field, PositiveInt + +if TYPE_CHECKING: + from neo4j_graphrag.types import RetrieverResult, RetrieverResultItem + +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 paths for a single retrieved source.""" + + 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 + cypher: str | None = None + + +class ExplainResult(BaseModel): + """Structured provenance for a GraphRAG answer.""" + + sources: list[SourceRef] + trace: TraceStep + 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"} +) + + +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") + 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]: + 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 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 + 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 + + paths_data = metadata.get("paths") + if paths_data: + paths = serialize_paths(paths_data) + return GraphContext(paths=paths) if paths else None + return 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()} + 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 = _json_safe_value(dict(node.items())) + if not isinstance(properties, dict): + properties = {} + return GraphNodeRef( + id=node.element_id, + labels=labels, + properties=properties, + ) + + +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: + start_node = relationship.start_node + end_node = relationship.end_node + return GraphRelationshipRef( + type=relationship.type, + 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, + ) + + +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 isinstance(paths_value, neo4j.graph.Path): + return [serialize_neo4j_path(paths_value)] + 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)) + 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 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 not paths: + return None + return GraphContext(paths=paths) + + +def vector_cypher_explain_result_formatter( + record: neo4j.Record, + *, + content: str, + score_key: str = "similarityScore", + graph_builder: Callable[[neo4j.Record], GraphContext | None] | None = None, +) -> RetrieverResultItem: + from neo4j_graphrag.types import RetrieverResultItem + + 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=metadata, + ) + + +def text2cypher_explain_result_formatter( + record: neo4j.Record, + *, + graph_builder: Callable[[neo4j.Record], GraphContext | 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 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/src/neo4j_graphrag/generation/graphrag.py b/src/neo4j_graphrag/generation/graphrag.py index 49f3dd3d5..b026ec8c1 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()) @@ -153,8 +165,23 @@ 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: - 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 +189,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 +210,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 +218,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/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 new file mode 100644 index 000000000..57ff05374 --- /dev/null +++ b/tests/unit/test_explain.py @@ -0,0 +1,341 @@ +# 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 ( + ExplainConfig, + ExplainResult, + GraphContext, + GraphNodeRef, + GraphRelationshipRef, + TraceStep, + build_explain_result, + format_retrieval_context, + graph_from_retriever, + serialize_neo4j_path, + 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 + + +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" + 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: + trace = trace_from_retriever(RetrieverResult(items=[])) + + assert trace.retriever == "unknown" + + +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": graph_context}, + ) + ] + ) + + graph = graph_from_retriever(result) + + assert graph is not None + assert len(graph) == 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" + + +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: + movie = GraphNodeRef(labels=["Movie"], properties={"title": "Avatar"}) + graph_context = GraphContext(paths=[[movie]]) + result = RetrieverResult( + items=[ + RetrieverResultItem( + content="Movie: Avatar", + metadata={"score": 0.89, "graph": graph_context}, + ) + ], + metadata={"__retriever": "VectorCypherRetriever"}, + ) + + explain = build_explain_result(result) + + assert explain.trace.retriever == "VectorCypherRetriever" + assert len(explain.sources) == 1 + assert explain.graph 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: + 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"] + + +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_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) + + 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: + 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( + paths=[[node_ref, GraphRelationshipRef(type="ACTED_IN"), node_ref]], + ) + ], + ).model_dump(mode="json") + + +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" + assert item.metadata is not None + assert item.metadata.get("graph") is None + + +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 + 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_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 6865845f0..8f72a06c6 100644 --- a/uv.lock +++ b/uv.lock @@ -2974,7 +2974,7 @@ wheels = [ [[package]] name = "neo4j-graphrag" -version = "1.15.0" +version = "1.18.0" source = { editable = "." } dependencies = [ { name = "fsspec" }, @@ -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" },