DatasourceResolverNode
Overview
- Picks the datasources a question goes to: an explicit override, the only registered datasource, or routing (five signals fused with reciprocal rank fusion) when more than one is registered.
- Enforces RBAC and the schema version mismatch policy before planning.
- Refuses a question no datasource can answer, with one small structured LLM call, before the decomposer runs.
- Sits at the entry of the control graph and gates the pipeline.
- Class:
DatasourceResolverNode - Source:
packages/nl2sql/src/nl2sql/pipeline/nodes/datasource_resolver/node.py
Responsibilities
- Choose candidate datasources:
- an explicit
datasource_idis used as given; - with exactly one datasource registered, that one is used and no vector search runs;
- otherwise
Retriever.route()ranks the datasources the caller's scope reaches (see Routing).
- an explicit
- Validate datasource IDs against the registered adapter list.
- Enforce RBAC datasource access.
- Judge answerability over the allowed candidates, refuse with
QUESTION_NOT_ANSWERABLEwhen none can answer, and forward only the candidates the verdict names, at mostsettings.routing.max_datasourcesof them. - Give each forwarded datasource its description and a capped table list, for the decomposer to route on.
- Populate
DatasourceResolverResponseinGraphState.
Position in Execution Graph
Upstream:
- Entry point (no upstream nodes), or CuratedMatcherNode when curated answers are configured and it fell back.
Downstream:
- DecomposerNode if resolution succeeds.
- Terminates early (END) if no datasource is allowed, or the question is not answerable.
Trigger conditions: - The control graph's entry node, unless curated answers are configured; then it runs whenever the curated matcher did not answer.
flowchart LR
Resolver[DatasourceResolverNode] -->|answerable and allowed| Decomposer[DecomposerNode]
Resolver -->|denied or not answerable| EndNode[END]
Inputs
From GraphState:
user_query(str, required): the question; used for routing (more than one datasource) and for the answerability check.datasource_id(Optional[str]): explicit override for datasource selection.user_context(UserContext, required): RBAC enforcement.
Context dependencies (Runtime):
vector_store: the run's pinnedRetriever. Itsrouteranks the candidates when more than one datasource is registered and no override is given, and gives each datasource's live schema version and snapshot (the snapshot's description and table list feed the answerability check and the decomposer's routing context).ds_registry: adapter registry for registered IDs.rbac: filters allowed datasources.llm_registry: the answerability check's model, agent namedatasourceresolver(falls back todefault).settings.routing(candidates,max_datasources,floor_ratio) andsettings.retrieval.decomposer_tables.
Validation performed:
- Datasource override must exist in the registry.
- RBAC must allow at least one candidate.
- At least one allowed candidate must be judged able to answer.
Outputs
Mutations to GraphState:
datasource_resolver_response(DatasourceResolverResponse)errors(list ofPipelineError) on failurereasoning(how the candidates were chosen, and the answerability reason) andwarnings
Side effects:
- One LLM call (the answerability check) once the role check passes.
- Routing's five index searches (one embedding of the question) when more than one datasource is registered and no override is given.
Internal Flow (Step-by-Step)
- Candidates:
state.datasource_idset: it must be registered (INVALID_STATEotherwise); it is the only candidate.- Exactly one datasource registered: it is the only candidate; no vector search.
- Otherwise: the vector store must exist (
SCHEMA_RETRIEVAL_FAILEDotherwise). The scope filter comes first: the registered datasources the caller's role may not read are left out, and none left is aCRITICALSECURITY_VIOLATIONwith no search and no model call.Retriever.routethen ranks the rest (the description, example, verified-query, table and column signals, fused with reciprocal rank fusion), keeping the topsettings.routing.candidates(3) and dropping any whose best similarity is undersettings.routing.floor_ratio(0.5) times the most similar one's. Only the tables the role may read vote: a table or column entry of a hidden table, or a verified query that reads one, is dropped before ranking and never reaches the trace. The description signal ranks every datasource entry exactly, so no reachable datasource is missed by the approximate search. No candidate isSCHEMA_RETRIEVAL_FAILED(with a re-index hint when the index is empty).
- Role check: candidates the role may not read are dropped. None left is a
CRITICALSECURITY_VIOLATION, and no model call is made. - Schema version mismatch policy (
failreturnsINVALID_STATE;warnadds a warning). - Answerability check over the allowed candidates only (see below). An empty verdict returns
QUESTION_NOT_ANSWERABLE. - Narrow to the verdict. After routing, only the allowed candidates the verdict names go forward, at most
settings.routing.max_datasources(3) of them, the best by fused score first; a candidate the verdict rejects never reaches the decomposer, and a verdict naming only unknown ids isQUESTION_NOT_ANSWERABLE. With an override or a single datasource there is one candidate, and a non-empty verdict accepts it. - Attach routing context to each forwarded datasource, from the snapshot its live build was made from:
description,tables(sorted bare table names, at mostsettings.retrieval.decomposer_tables, 30) andmore_tables(how many were left out). - Return
DatasourceResolverResponsewith the forwarded datasources (sorted by id), their IDs asallowed_datasource_ids, and unsupported IDs. - On exceptions, log and return
SCHEMA_RETRIEVAL_FAILEDwith the exception text; a provider's refusal of the model call is aPROVIDER_*error instead (see provider failures).
A single registered datasource is the shortcut, not the usual case: with more than one - the demo registers three - routing runs, and nothing else in the flow changes.
Order of checks
The role check runs before the answerability check, so:
- a caller with no allowed datasource gets the RBAC refusal and the model is never asked;
- the model sees only datasources the role may read;
- a question that is answerable but touches tables the role may not read passes this node and is refused by
LogicalValidatorNode's table policy, as before.
Answerability check
A structured call (AnswerabilityResponse: answerable_datasource_ids, reason) with this prompt (datasource_resolver/prompts.py). The system message:
You check whether a question can be answered from the datasources below, before any SQL is planned. For each datasource you see its description and the tables it holds, not its data.
Rules:
1. List the id of every datasource that could hold data relevant to the question, even partly. A question that needs several datasources lists all of them.
2. Be conservative. When you are unsure, treat the question as answerable and list the datasources that might answer it. Refusing a question that could have been answered is worse than running one that finds nothing.
3. Return an empty list only when the question is clearly about something none of these datasources hold, such as the weather, general knowledge, or small talk.
4. Use only ids from the list below.
5. Give the reason in one short sentence.
Datasources (JSON):
{datasources}
The human message:
{datasources}is a JSON list, one entry per allowed candidate, sorted by id, with sorted keys:description(the datasource description from the live build's snapshot, which indexing takes from the datasources config) andtables(sorted bare table names).- The instructions and datasources come first (the system message) and the question last (the human message), so the prompt is byte-identical across questions up to the question and a provider's prompt cache can serve the prefix. OpenAI caches only prompts of 1,024 tokens or more, so with one small datasource nothing is cached; the prefix grows, and becomes cacheable, with more datasources.
- The verdict refuses and narrows: an empty list refuses the question, and after routing only the allowed candidates it names reach the decomposer. It cannot add a datasource the role may not read.
- For Chinook on its own the rendered prompt is about 260 tokens (
o200k_base), plus theAnswerabilityResponsefunction schema; the demo now registers three datasources, so its prompt is correspondingly longer.
Contracts & Interfaces
Implements a LangGraph node callable:
Key contracts:
DatasourceResolverResponseResolvedDatasourceAnswerabilityResponsePipelineError
Determinism Guarantees
- Candidate choice is deterministic for an override or a single datasource.
- Routing's ranking may vary with more than one datasource (approximate searches; ties in fused score go to the smaller id).
- The answerability prompt is serialized deterministically; the verdict is a model call at the configured temperature and seed.
Error Handling
Emits PipelineError with:
INVALID_STATE(unknown datasource override; schema version mismatch underfail)SECURITY_VIOLATION(RBAC denial)QUESTION_NOT_ANSWERABLE(ERROR: no allowed datasource can answer the question;QueryResult.statusiserror)SCHEMA_RETRIEVAL_FAILED(no candidates, no vector store with several datasources, or exceptions)
Exceptions are caught at the node boundary and logged with logger.error.
Retry + Idempotency
- No internal retry logic.
- Idempotent for the same input state, except vector retrieval ranking and the model's verdict may vary.
Performance Characteristics
- One small LLM call per question once the role check passes; it saves the decomposer, planner and synthesizer calls on every unanswerable question.
- Routing (one question embedding, five index searches, no model) only with more than one datasource.
- RBAC checks are in-memory operations; schema lookups are store reads.
Observability
- Logger:
datasource_resolver - The answerability call is recorded like every LLM call: usage under node
datasource_resolverinQueryResult.usage, and in the run trace keyed by node, sub-query, attempt and call. - Vector retrieval uses
VECTOR_BREAKERat the vector store layer. - Returns a
retrievalrecord in its update for the run trace: the query embedded, one search record per routing signal (route.description,route.examples,route.curated,route.tables,route.columns: the pool with similarities, each datasource's best entry picked) and aroutingrecord (each signal's datasource ranking, the fused scores and ranks, the datasources kept and those dropped with why); orskipped: trueand why (an explicitdatasource_id, a single registered datasource, no vector store). The answerability check is not part of it: it is an LLM call and is traced as one. See Debugging a run.
Configuration
- LLM agent
datasourceresolverin the LLM config (optional; see LLM configuration) settings.routing.candidates,max_datasourcesandfloor_ratio, andsettings.retrieval.decomposer_tables(see Settings)
Extension Points
- Replace this node in
build_graph()to change resolution behavior. - Extend retrieval by modifying the node or the vector store retrieval strategy.
Known Limitations
- Tenant scoping is not implemented in vector retrieval.
- The answerability check sees table names, not columns or data, so it refuses only questions clearly outside every datasource.
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/datasource_resolver/node.pypackages/nl2sql/src/nl2sql/pipeline/nodes/datasource_resolver/prompts.pypackages/nl2sql/src/nl2sql/pipeline/nodes/datasource_resolver/schemas.py