Free text vs a validated object
Every QueryEngine so far (Lesson 5, 01_beginner) hands back a Response whose .response is a plain string, the LLM's synthesized answer in its own words. That's fine for a human reading it, but brittle for a program: if you need max_days as an int to feed into a conditional, you'd have to regex it out of a sentence.
LlamaIndex's output_cls parameter fixes this by asking the LLM to fill in a Pydantic model's fields instead of writing free text, then validating the result against that model before returning it. A malformed or missing field raises instead of silently handing you a broken string to parse.
| Free-text QueryEngine | output_cls-constrained QueryEngine | |
|---|---|---|
.response type | str | An instance of your Pydantic model |
| Shape guaranteed? | No, whatever the LLM wrote | Yes, validated against the model |
| Good for | A human reading the answer | Feeding the answer into more code |
| LangChain equivalent | model.invoke(prompt) | model.with_structured_output(Schema) |
Two ways to get structured output
This lesson shows both, verified against this repo's installed llama-index-core (0.14.23):
1. index.as_query_engine(output_cls=SomeModel), RAG plus structure: retrieve relevant Nodes, then synthesize an answer shaped like SomeModel instead of a sentence. 2. Settings.llm.as_structured_llm(SomeModel), structure with no index or retrieval: wrap the LLM directly when you already have the text and just want it coerced into a schema.
Both routes go through the same underlying mechanism, LlamaIndex builds a JSON-schema-constrained prompt from your Pydantic model and validates the LLM's response against it with Pydantic itself.
The code, piece by piece
class PolicySummary(BaseModel): topic: str = Field(description="What policy this summary is about, in a few words") max_days: int = Field(description="The maximum number of days mentioned in the policy, as an integer") summary: str = Field(description="A one-sentence plain-English summary of the policy")A plain Pydantic model describing the *shape of the answer*, not the question. Field descriptions matter here, they get included in the schema the LLM is asked to fill in, so a clear description is doing real prompting work, not just documentation.
query_engine = index.as_query_engine(output_cls=PolicySummary)response = query_engine.query("Summarize the vacation policy's carryover rule...")Same as_query_engine() call as Lesson 5, plus one new keyword. Retrieval still happens exactly as before, only the response synthesis step changes shape.
parsed: PolicySummary = response.responseprint(parsed.max_days)response.response is now a PolicySummary instance, not a string. Normal attribute access, normal types, no string-parsing.
structured_llm = Settings.llm.as_structured_llm(PolicySummary)direct = structured_llm.complete("Vacation policy: employees carry over at most 5 unused vacation days...")direct_parsed: PolicySummary = direct.rawThe index-free route. .complete() returns a CompletionResponse whose .raw attribute holds the parsed Pydantic object, useful when you already have the source text in hand (e.g. from your own retrieval, or a paragraph pasted into the prompt) and only need the schema-coercion part.
Checkpoint
output_cls: pass a Pydantic model toas_query_engine()(oras_structured_llm()on an LLM directly) to get a validated object back instead of free text.index.as_query_engine(output_cls=Model)combines retrieval with structured synthesis, RAG plus a schema.Settings.llm.as_structured_llm(Model)skips retrieval entirely, useful when you already have the source text.- Field
descriptions in your Pydantic model are part of the prompt, not just documentation, write them for the LLM as much as for future readers of your code. - Structured output guarantees *shape*, not which facts get included, that's still governed by retrieval and the source text given to the model.
If anything here still feels unclear, ask before moving to Lesson 11.