AnswerSynthesizerNode
Overview
- Summarizes aggregated results into a user‑facing response using an LLM.
- Exists to convert structured results into human‑readable output.
- Sits after
EngineAggregatorNodeat the end of the control graph. - Class:
AnswerSynthesizerNode - Source:
packages/nl2sql/src/nl2sql/pipeline/nodes/answer_synthesizer/node.py
Responsibilities
- Send the model a preview of each result, never the whole of it: the first
settings.results.preview_rowsrows (20), the total row count, the column names and apartialflag. - Say when a result was cut at the row limit, whatever the model wrote.
- Include unmapped sub‑queries in the response context.
- Invoke LLM with structured output schema.
Position in Execution Graph
Upstream:
- EngineAggregatorNode, after a generated run or a verified one (the curated matcher ran the query)
Downstream: - Graph end.
Trigger conditions:
- Executed after aggregation succeeds. When the aggregator fails (AGGREGATOR_FAILED), aggregator_route ends the run instead, so this node never spends a model call on an empty result.
flowchart LR
Aggregator[EngineAggregatorNode] --> Synthesizer[AnswerSynthesizerNode] --> EndNode[PipelineEnd]
Inputs
From GraphState:
user_query(required)aggregator_response.terminal_results(required): the first rows of each terminal resultaggregator_response.computed_artifacts: the saved result behind each, for its row count and columnsdecomposer_response.unmapped_subqueries(optional)subgraph_outputs[<id>].truncated/.row_cap: whether a sub-query's result reached its row limit, and at how many rows.verified_match(optional): set when a verified query answered. Its qualified id and itsdescription(the data team's words for what the query returns, e.g. "invoiced revenue, before refunds") are added to the prompt as a Verified Query section, and the model is told to describe the numbers that way. A generated answer's prompt has no such section and is exactly what it was.warnings(RunWarnings): the sanity checks'VALUE_NOT_FOUND("There is no Genre with Name 'Polka'."),JOIN_FAN_OUT(a sum a join may have counted more than once) andALL_NULL_COLUMNwarnings (see ExecutorNode). A recovered one (detail.recovered) is not used: a retry replaced that attempt.
Validation performed:
- If no aggregated result is available, emits
INVALID_STATE.
Outputs
Mutations to GraphState:
answer_synthesizer_response(AnswerSynthesizerResponse):final_answeris the model'sAggregatedResponse(summary,format_type,content,warnings) plustruncated, True when a result was cut at the row limit.reasoningsummaryerrorson failure
Side effects:
- LLM invocation.
Internal Flow (Step-by-Step)
- Resolve aggregated result from
aggregator_response. - If missing, emit
INVALID_STATEerror and stop. - Build the result preview (
result_preview()): for each terminal result, its firstsettings.results.preview_rowsrows,row_count,columnsandpartial(true when rows were left out). The rows are the aggregator'sterminal_results, already cut to the preview;row_countandcolumnscome from the saved combined result incomputed_artifacts, so they describe the whole result. Serialize it to JSON. - Build the Result Limit note: one line per sub-query whose result reached its row limit ("The result was cut at 10,000 rows, the row limit, so it is incomplete. Narrow the question ..."), or
None. - Serialize unmapped sub‑queries to JSON.
- Invoke LLM chain with structured output. The prompt's Data Notes lists the sanity checks' notes and asks the answer to say them: that a value does not exist instead of reporting the empty result as a finding, that a total may count rows more than once, that a column has no values. When the run was started by
stream_query(configurable.stream_answer), the chain isstreaming_structured: the same request with the provider asked to stream, so the run'sRunEventscallback publishes each piece of the tool call as ananswer.deltaevent. The answer it returns is the unstreamed chain's (Streaming a run). - Append each data note and each result-limit note to the answer's
warnings, whatever the model wrote, setfinal_answer.truncated, and returnAnswerSynthesizerResponse. - On exception, emit
AGGREGATOR_FAILED.
The model sees a preview, not the result
The user sees the full result as a table; the model's job is to write about it, not to copy it. So the prompt carries a preview, and its instructions say:
- every number the answer states comes from the preview rows or from
row_count; - when the preview is
partial, do not total, average, rank or count over rows it cannot see; - do not reproduce the result: say the full result is in the table, and use
format_type"table"only for a few preview rows.
The prompt's size no longer grows with the result, which is what let the row cap
rise from 1,000 to a resource ceiling (settings.results.row_limit, 100,000).
A result cut at the row limit
The generator bounds each sub-query's SQL at its row limit. A result that comes
back with that many rows may have been cut, so the subgraph wrapper marks it
truncated (SubgraphOutput, QueryResult.sub_queries[].truncated,
QueryResult.truncated). The prompt's Result Limit section tells the model
the result was cut at N rows and asks it to say so and to suggest narrowing the
question; the node adds the same note to warnings itself, so a cut result is
never presented as complete even when the model leaves it out. A plan's own
top-N (LIMIT 5) is the answer, not a cut, and is never flagged.
Contracts & Interfaces
Implements a LangGraph node callable:
Key contracts:
AggregatedResponseAnswerSynthesizerResponse
Determinism Guarantees
- Non‑deterministic unless LLM configured deterministically.
Error Handling
Emits PipelineError with:
INVALID_STATE(no aggregated result)AGGREGATOR_FAILED(LLM failure), or aPROVIDER_*error when the provider refused the call (see provider failures)
Logs failures via logger.error.
Retry + Idempotency
- No internal retry logic.
- Idempotency depends on LLM determinism.
Performance Characteristics
- LLM call dominates latency and cost.
- The prompt holds at most
settings.results.preview_rowsrows per terminal result, whatever the result's size. The whole result is still held in memory by the aggregator.
Observability
- Logger:
answer_synthesizer - Adds reasoning summary to state.
Configuration
- LLM configuration for agent name
answer_synthesizerviallm.yaml. settings.results.preview_rows(default20): rows of each result the model sees.settings.results.row_limit(default100000): the row ceiling a result is cut at; see the system settings.- Both are read from the engine's runtime (
ctx.settings) when the node is built, so two engines in one process each use their own.
Extension Points
- Modify
ANSWER_SYNTHESIZER_PROMPT. - Replace node in
build_graph()for custom synthesis.
Known Limitations
- No deterministic summarization guarantees without LLM configuration.
- No fallback response when LLM fails.
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/answer_synthesizer/node.py