Skip to content

Commit 57f80e6

Browse files
Add support for structured output (LLMInterfaceV2 and LLMEntityRelationExtractor) (#463)
* Add response_format param to LLMInterfaceV2 * Raise NotImplementedError for Anthropic, Cohere, Ollama and MistralAI * Enable structured output for OpenAI llm interface * Enable structured output for VertexAI llm interface * Cleanups * Ruff * More cleanups * Remove response_format when passed via model_params with v2 to avoid conflicts * Fix access to GenerationConfig params for VertexAI * Make Noe4jGraph model more strict * Update entity relation extractor to enable use of structured output * Code improvements * Fix mypy issues * Fix issue of not passing kwargs in VertexAILLM v2 * Update Changelog * Update docs * Make Neo4jGraph much more strict (required by OpenAI structured output) * Convert internally pydantic to json_schema for OpenAI (avoid using beta.parse endpoint) * Add examples * Update changelog * Ruff * Remove unused import * Update unit tests
1 parent 62bbea3 commit 57f80e6

26 files changed

+1461
-144
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
- Support for Python 3.14
1010
- Support for version 6.0.0 of the Neo4j Python driver
11+
- Support for structured output in `OpenAILLM` and `VertexAILLM` via `response_format` parameter. Accepts Pydantic models (requires `ConfigDict(extra="forbid")`) or JSON schemas.
12+
- Added `use_structured_output` parameter to `LLMEntityRelationExtractor` for improved entity extraction reliability with OpenAI/VertexAI LLMs.
1113

1214
### Changed
1315

1416
- Switched project/dependency management from Poetry to uv.
1517
- Dropped support for Python 3.9 (EOL)
16-
18+
- Made `Neo4jNode`, `Neo4jRelationship`, and `Neo4jGraph` stricter: properties field now uses typed `PropertyValue` (Neo4j primitives, temporal values, lists, `GeoPoint`) and fixed mutable defaults with `Field(default_factory=...)`.
1719

1820
## 1.11.0
1921

docs/source/types.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ Neo4jGraph
7070

7171
.. autoclass:: neo4j_graphrag.experimental.components.types.Neo4jGraph
7272

73+
GeoPoint
74+
========
75+
76+
.. autoclass:: neo4j_graphrag.experimental.components.types.GeoPoint
77+
7378
KGWriterModel
7479
=============
7580

docs/source/user_guide_kg_builder.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,26 @@ It can be used in this way:
932932
The LLM to use can be customized, the only constraint is that it obeys the :ref:`LLMInterface <llminterface>`.
933933

934934

935+
Using Structured Output
936+
-----------------------
937+
938+
For improved reliability and type safety with :ref:`OpenAILLM <openaillm>` or :ref:`VertexAILLM <vertexaillm>`, enable structured output mode. When `use_structured_output=True`, the extractor uses the LLMInterfaceV2, passing the `Neo4jGraph` Pydantic model as `response_format` to `invoke()`. This ensures the LLM response conforms to the expected graph structure with automatic type validation, reducing the need for JSON repair and error handling.
939+
940+
.. code:: python
941+
942+
from neo4j_graphrag.experimental.components.entity_relation_extractor import (
943+
LLMEntityRelationExtractor,
944+
)
945+
from neo4j_graphrag.llm import OpenAILLM
946+
947+
llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0})
948+
extractor = LLMEntityRelationExtractor(llm=llm, use_structured_output=True)
949+
950+
.. note::
951+
952+
Using `use_structured_output=True` with other LLM providers will raise a `ValueError`. Do not pass `response_format` in constructor parameters (`model_params` or `generation_config`); the extractor automatically sets it when calling `invoke()`.
953+
954+
935955
Error Behaviour
936956
---------------
937957

docs/source/user_guide_rag.rst

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,87 @@ Here's an example using the Python Ollama client:
295295
See :ref:`llminterface`.
296296

297297

298+
Structured Output with LLMs
299+
============================
300+
301+
Structured output enables LLMs to return responses conforming to a predefined schema (Pydantic model or JSON schema), ensuring type-safe and consistent data structures. This is useful for extracting entities, relationships, or any structured data with automatic validation.
302+
303+
**V2 Interface (Recommended)**: For :ref:`OpenAILLM <openaillm>` and :ref:`VertexAILLM <vertexaillm>`, pass `response_format` as a parameter to the `invoke()` method when using the V2 interface (list of `LLMMessage`). The `response_format` accepts either a Pydantic model class or a JSON schema dictionary. Other LLM providers will raise `NotImplementedError` if `response_format` is used.
304+
305+
**V1 Interface (Legacy)**: With the V1 interface (string input), standard JSON mode is supported for both OpenAI and VertexAI via constructor parameters only (`model_params` for OpenAI, `generation_config` for VertexAI). The `response_format` parameter in `invoke()` is not permitted with V1.
306+
307+
.. code:: python
308+
309+
from pydantic import BaseModel, ConfigDict
310+
from neo4j_graphrag.llm import OpenAILLM
311+
from neo4j_graphrag.types import LLMMessage
312+
313+
class Person(BaseModel):
314+
model_config = ConfigDict(extra="forbid") # Required for OpenAI structured output
315+
316+
name: str
317+
age: int
318+
occupation: str
319+
320+
llm = OpenAILLM(model_name="gpt-4o-mini")
321+
322+
# V2: Pass response_format to invoke()
323+
messages = [LLMMessage(role="user", content="Extract: John is a 30 year old engineer.")]
324+
response = llm.invoke(messages, response_format=Person, temperature=0)
325+
person = Person.model_validate_json(response.content) # {"name": "John", "age": 30, ...}
326+
327+
# V1: Use constructor parameters for standard JSON mode
328+
llm_v1 = OpenAILLM(
329+
model_name="gpt-4o-mini",
330+
model_params={"response_format": {"type": "json_object"}, "temperature": 0}
331+
)
332+
response_v1 = llm_v1.invoke("Extract person in JSON format: John is 30 years old.")
333+
334+
OpenAI Structured Output
335+
-------------------------
336+
337+
OpenAI supports Pydantic models, JSON schemas, and JSON object mode. **Important**: Pydantic models must include `ConfigDict(extra="forbid")` to generate schemas with `additionalProperties: false`, which is required by OpenAI's strict mode.
338+
339+
.. code:: python
340+
341+
from neo4j_graphrag.llm import OpenAILLM
342+
from neo4j_graphrag.types import LLMMessage
343+
344+
llm = OpenAILLM(model_name="gpt-4o-mini")
345+
346+
# JSON schema
347+
json_schema = {
348+
"type": "object",
349+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
350+
"required": ["name", "age"]
351+
}
352+
messages = [LLMMessage(role="user", content="Extract: John is 30.")]
353+
response = llm.invoke(messages, response_format=json_schema, temperature=0)
354+
355+
# JSON object mode (no schema enforcement)
356+
response = llm.invoke(messages, response_format={"type": "json_object"}, temperature=0.5)
357+
358+
VertexAI Structured Output
359+
---------------------------
360+
361+
VertexAI uses `GenerationConfig` with `response_mime_type` and `response_schema` internally when `response_format` is passed to `invoke()`. Both Pydantic models and JSON schemas are supported. **Important**: Additional `GenerationConfig` parameters (e.g., `temperature`, `max_output_tokens`) can be passed as kwargs to `invoke()`.
362+
.. code:: python
363+
364+
from pydantic import BaseModel, ConfigDict
365+
from neo4j_graphrag.llm import VertexAILLM
366+
from neo4j_graphrag.types import LLMMessage
367+
368+
class Person(BaseModel):
369+
model_config = ConfigDict(extra="forbid")
370+
371+
name: str
372+
age: int
373+
374+
llm = VertexAILLM(model_name="gemini-1.5-pro")
375+
messages = [LLMMessage(role="user", content="Extract: John is 30.")]
376+
response = llm.invoke(messages, response_format=Person, temperature=0)
377+
person = Person.model_validate_json(response.content)
378+
298379
Rate Limit Handling
299380
===================
300381

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
Simple example demonstrating structured output with LLMEntityRelationExtractor.
3+
4+
This example shows how to use structured output for more reliable entity and
5+
relationship extraction with automatic schema validation.
6+
7+
The Neo4jGraph schema is now compatible with both OpenAI and VertexAI structured
8+
output APIs, with strict schema validation (additionalProperties: false) and
9+
proper required field definitions.
10+
11+
Prerequisites:
12+
- Google Cloud credentials configured for VertexAI
13+
- Or OpenAI API key set in OPENAI_API_KEY environment variable
14+
"""
15+
16+
import asyncio
17+
from dotenv import load_dotenv
18+
19+
from neo4j_graphrag.experimental.components.entity_relation_extractor import (
20+
LLMEntityRelationExtractor,
21+
)
22+
from neo4j_graphrag.experimental.components.types import (
23+
Neo4jGraph,
24+
TextChunk,
25+
TextChunks,
26+
)
27+
from neo4j_graphrag.llm import VertexAILLM
28+
29+
30+
async def main() -> Neo4jGraph:
31+
"""
32+
Demonstrates entity and relation extraction with structured output.
33+
34+
With use_structured_output=True:
35+
- Uses LLMInterfaceV2 (list of messages)
36+
- Passes Neo4jGraph Pydantic model as response_format to invoke()
37+
- Ensures response conforms to expected graph structure
38+
- Provides automatic type validation
39+
- Reduces need for JSON repair and error handling
40+
"""
41+
load_dotenv()
42+
# Initialize LLM - no response_format in constructor!
43+
llm = VertexAILLM(model_name="gemini-2.5-flash")
44+
45+
# llm = OpenAILLM(
46+
# model_name="gpt-4o-mini",
47+
# model_params={"temperature": 0}
48+
# )
49+
50+
# Enable structured output for reliable extraction
51+
extractor = LLMEntityRelationExtractor(
52+
llm=llm,
53+
use_structured_output=True, # This is the key parameter!
54+
)
55+
56+
# Sample text about a person and organization
57+
sample_text = """
58+
Albert Einstein was a theoretical physicist who developed the theory of relativity.
59+
He worked at the Institute for Advanced Study in Princeton from 1933 until his death in 1955.
60+
"""
61+
62+
# Extract entities and relationships
63+
graph = await extractor.run(
64+
chunks=TextChunks(chunks=[TextChunk(text=sample_text, index=0)])
65+
)
66+
67+
return graph
68+
69+
70+
if __name__ == "__main__":
71+
# Run extraction
72+
graph = asyncio.run(main())
73+
74+
print(graph)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright (c) "Neo4j"
2+
# Neo4j Sweden AB [https://neo4j.com]
3+
# #
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
# #
8+
# https://www.apache.org/licenses/LICENSE-2.0
9+
# #
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""
16+
Simple example comparing OpenAI LLM V1 (legacy) vs V2 (structured output).
17+
18+
This demonstrates how V2's structured output provides type-safe, validated responses
19+
compared to V1's prompt-based JSON extraction.
20+
21+
Prerequisites:
22+
- OpenAI API key set in OPENAI_API_KEY environment variable
23+
"""
24+
25+
from dotenv import load_dotenv
26+
from pydantic import BaseModel, ConfigDict
27+
from neo4j_graphrag.llm import OpenAILLM
28+
from neo4j_graphrag.types import LLMMessage
29+
30+
load_dotenv()
31+
32+
33+
# Define a Pydantic model for structured output
34+
class Movie(BaseModel):
35+
model_config = ConfigDict(
36+
extra="forbid"
37+
) # This is important to prevent extra properties from being added to the response
38+
39+
title: str
40+
year: int
41+
director: str
42+
genre: str
43+
44+
45+
# =============================================================================
46+
# V1 (Legacy): Manual JSON mode with prompt engineering
47+
# =============================================================================
48+
print("=" * 60)
49+
print("V1 Legacy: Manual JSON extraction with prompt engineering")
50+
print("=" * 60)
51+
52+
# V1: Use model_params with response_format for JSON object mode
53+
llm_v1 = OpenAILLM(
54+
model_name="gpt-4o-mini",
55+
model_params={"response_format": {"type": "json_object"}, "temperature": 0},
56+
)
57+
58+
# V1 requires string input and explicit JSON instructions in the prompt
59+
v1_prompt = """Extract movie information and respond in JSON format.
60+
Include: title, year, director, genre.
61+
62+
Text: Inception was directed by Christopher Nolan in 2010. It's a science fiction thriller."""
63+
64+
response_v1 = llm_v1.invoke(v1_prompt)
65+
print(f"Response: {response_v1.content}")
66+
67+
68+
# =============================================================================
69+
# V2 (New): Structured output with Pydantic model
70+
# =============================================================================
71+
print("\n" + "=" * 60)
72+
print("V2: Structured output with Pydantic model")
73+
print("=" * 60)
74+
75+
# V2: Use clean LLM without constructor params
76+
llm_v2 = OpenAILLM(model_name="gpt-4o-mini")
77+
78+
# V2 uses list of LLMMessage for input
79+
messages = [
80+
LLMMessage(
81+
role="user",
82+
content="Inception was directed by Christopher Nolan in 2010. It's a science fiction thriller.",
83+
)
84+
]
85+
86+
# Pass response_format and temperature directly to invoke()
87+
response_v2 = llm_v2.invoke(messages, response_format=Movie, temperature=0)
88+
89+
# Parse and validate in one step
90+
movie = Movie.model_validate_json(response_v2.content)
91+
print(f"Response: {response_v2.content}")
92+
93+
94+
# =============================================================================
95+
# V2: Using JSON Schema instead of Pydantic
96+
# =============================================================================
97+
print("\n" + "=" * 60)
98+
print("V2 Alternative: Structured output with JSON Schema")
99+
print("=" * 60)
100+
101+
# V2: Use clean LLM without constructor params
102+
llm_v2 = OpenAILLM(model_name="gpt-4o-mini")
103+
104+
# V2 uses list of LLMMessage for input
105+
messages = [
106+
LLMMessage(
107+
role="user",
108+
content="Inception was directed by Christopher Nolan in 2010. It's a science fiction thriller.",
109+
)
110+
]
111+
112+
# Define a JSON schema (equivalent to the Movie Pydantic model)
113+
# Note: OpenAI requires JSON schemas to be wrapped in this specific format
114+
movie_schema = {
115+
"type": "json_schema",
116+
"json_schema": {
117+
"name": "movie_info",
118+
"schema": {
119+
"type": "object",
120+
"properties": {
121+
"title": {"type": "string"},
122+
"year": {"type": "integer"},
123+
"director": {"type": "string"},
124+
"genre": {"type": "string"},
125+
},
126+
"required": ["title", "year", "director", "genre"],
127+
"additionalProperties": False,
128+
},
129+
},
130+
}
131+
132+
# Pass JSON schema as response_format
133+
response_v2_schema = llm_v2.invoke(
134+
messages, response_format=movie_schema, temperature=0
135+
)
136+
137+
print(f"Response: {response_v2_schema.content}")

0 commit comments

Comments
 (0)