Engineering NotesDesign principles and architecture
RAG can't count or sum rows, so we ask the source database in SQL

Ask "How many orders have gone unprocessed for more than 30 days?" through document search (RAG), and the model counts the orders in the few chunks the search returned, then reports that number as the total. Search returns the chunks most similar to the question, not every row that meets the condition. The answer looks fine even when the number is wrong, so the reader has no way to tell.
So answers that only exist once rows are counted and grouped, such as counts, sums and trends over time, are asked of the source database in SQL. The model picks from eight database tools that sit in the same tool list as document search. So that it doesn't mistake the number of rows it received for a total, the execution tool says in its result when the result was truncated. This post covers how we split those tools, how a truncated result is flagged, and how a question that needs both documents and data is answered in one turn.
A handful of search chunks can't give you a total
Some business questions, like "What are the criteria for settling travel expenses?", have their answer written as a sentence in a document. Find that sentence, attach it to the model, and you're done. RAG handles these well.
"Sales by region last month" and "the number of orders unprocessed for more than 30 days" are different. No document states the answer. The number only comes into being when rows are selected by a condition, counted and grouped. Embedding search returns the top k chunks closest to the question. If 300 orders meet the condition but the top k chunks happen to contain 12 of them, the model answers "12 in total." It has no way of knowing that more rows exist outside the search results.
Exporting tables into an index also breaks freshness and permissions
The common way to bring database data into RAG is to export tables periodically, turn them into text and index them like documents. It reuses the existing document search pipeline and puts no load on the source database at question time. But besides totals, three more things go wrong.
| What goes wrong | What happens |
|---|---|
| Freshness | The index holds the values from the moment of export. Values that change several times a day, such as order status, stay wrong until the next export. |
| Aggregation | Sums, averages and trends over time require scanning every row. For the model to compute them itself, every row has to go into its context, and tens of thousands of rows don't fit. |
| Permissions | The source database already decides which tables each account can see. Flatten the rows into text in one index and that separation has to be rebuilt at the document level. |
The source database already does all four of these well. So when the answer is in the database, we ask the database.
The model finds the table, checks the columns, then runs SQL
Database access is not a single "run SQL" tool. We split it into eight tools so the model can pick one at each step.
| Tool | What it does |
|---|---|
| Database list | Returns the connected databases and their analysis status. |
| Database profile | Describes what business data a database holds. Used when it's unclear which database to look at. |
| Table search | Finds candidate tables for the question by meaning. It can search several databases in one call. |
| Table name list | Returns the names of the analyzed tables in one database. |
| Table card | Returns one table's columns, keys, statistics, code values and join paths, with the meaning of each column, on a single card. |
| Join paths | Returns join paths between tables, keeping foreign keys declared in the database separate from relationships inferred by analysis. |
| Execution plan | Shows the execution plan of a read-only SQL statement. |
| Read-only execution | Runs one read-only SQL statement against the source database. |
The system prompt sets table search → table card → execution plan (if the query looks heavy) → read-only execution as the default order. It tells the model to check columns on the table card for every table it will reference before writing SQL. When the model guesses a column name, the SQL either fails or, worse, runs against a different column with a similar name.
In the first turn's input, the databases the user can query are listed by name together with their engine, such as MariaDB or Tibero. The model uses the engine to fit date functions and identifier quoting to that database's SQL dialect.
The material for table cards is prepared in advance. When an administrator runs schema analysis, the meaning of each column, its code values and undeclared join relationships are stored for every table (“DDL doesn't say what a column means, so we read its values first”). Numbers are not stored in advance. What is prepared ahead of time is "which column in which table holds what"; "what is the value now" is read from the source database every time a question is asked.
When a result is truncated, the execution tool says so
Asking the database directly can still produce a wrong total. The model adds LIMIT 200 to its SQL, receives 200 rows and answers "200 in total." It is the same mistake as reading search chunks as a total.
So the read-only execution tool returns whether the result was truncated along with the rows. It marks the result truncated if any of the following caps is hit.
| Cap | Behavior |
|---|---|
| The tool's row cap | 500 rows by default. If the model asks for more, the value is lowered to the installation's maximum (dbConnection.maxRowLimit, 10,000 by default). The executor reads one row past the cap, and if that row arrives, the result is marked truncated. |
| The SQL's own cap | If as many rows arrive as the SQL's LIMIT or FETCH FIRST allows, the result is marked truncated. The database stops at that point, so reading one extra row can't detect it, and it is counted separately. Statements that can return only one row, such as a SQL that only does COUNT(*), are excluded. |
| Result size | If the accumulated size of the cells passes the installation's limit (dbConnection.resultMaxBytes, 16 MB by default), the remaining rows are not fetched and the result is marked truncated. |
The tool description says how to read the flag: the row count in a result is the number of rows received, never a total, and to answer "how many" the model must count again in SQL with COUNT(*) or GROUP BY. If the model were only told "don't report the row count as a total," it would have no way to know when a result was cut off. Whether a result was truncated is a fact the executor can determine, so the executor determines it. Requests that need the full result are handled by handing the original over as a file (“Query results go to a file so the model never retypes them”).
The criteria come from documents and the number from the database, in one turn
Document search and the database tools sit in the same tool list of the same loop (“One agent loop for every feature, with the differences in hooks”). When one question needs both, a single turn uses them in sequence. A turn is one question-and-answer exchange, from receiving a question to producing the answer.
Ask "How many orders exceed the long-pending threshold in our policy?" and the model first uses document search to find the definition of "long-pending" in the policy. If it says 30 days after receipt, the model uses table search to find the orders table and checks the receipt date and processing status columns on the table card. It then runs a COUNT(*) SQL with that threshold as the condition through the read-only execution tool. The answer carries the criteria from the document and the count from the database together. In the reasoning trace above the answer, the user can open each step and see what was sent to the tool and what came back.
Documents and databases each apply their own permissions and scope
The two tool sets share a loop, but permissions are checked separately. Documents are filtered by the access granted on each document collection, and databases by the scope allowed to the role: whole databases for directly connected ones, down to individual tables for those connected through OwlDB. Tables and documents that match a search but fall outside the user's permissions are returned separately, by name only. The model uses those names to say the data exists but the user doesn't have permission to view it, and does not try SQL against those tables. Databases the user has no permission for are also listed by name only in the first turn's input, so that when the user asks about one, the model says they have no permission rather than treating it as a database that doesn't exist.
When the user mentions a specific database or document collection in the question, that turn's queries are bound to that scope. If the mentioned database doesn't yield an answer, the model does not read rows from another database. It uses table search, which touches only metadata, to find candidates in the other databases, asks the user which one to look at, and queries only the one the user picks. The tools reject table card and execution calls aimed at a database outside the scope.
Design choices
The cost of asking the source database instead of an index is load on the source database. When you search an exported copy, the source database never learns a question was asked; answering from current values means every question sends a query to it. So each query has a time budget (timeouts.dbQuerySeconds, 600 seconds by default), executions running at the same time against one database are capped by dbConnection.perDbConcurrency (4 by default), and connections use an account with only read privileges (“Read-only and query cancellation have to reach the database server”).
The default row cap of 500 is also a choice. A higher cap truncates less often, but every row received takes up the model's context. We let SQL do the counting and summing, and return to the model only as much as it needs to decide.
Chat answers only from tables that have completed schema analysis, because the model needs a table card to check columns and write SQL. The cost is that a newly created table isn't used in answers until it is analyzed. The schema check narrows that gap by finding new tables and showing them as Not analyzed (“Schema analysis goes stale, so only changed tables get reanalyzed”).
Splitting access into eight tools means a few more rounds before one SQL statement runs. Loading the table list and every column into the prompt up front would save those rounds, but a database with thousands of tables doesn't fit in a prompt. If you plan to connect a business system's database directly, set the time budget, the concurrency cap and a read-only account first, then connect it.
What problem does this solve?

Insights · Why AI built for work
Inventory right now: does the AI chatbot know today's count?
A sales rep asks the company's AI chatbot, "How many units of product A do we have in stock right now?" The ch…

Insights · Why AI built for work
Data scattered across systems: can AI gather it in one pass?
"Pull the accounts more than 90 days overdue this quarter that our policy says go to collections, and group th…

Insights · Why AI built for work
ChatGPT is brilliant. So why can't it do your company's work?
Ask a general-purpose AI service, "What were sales at each of our branches this month?" and the answer comes back in seconds.
More in Design principles and architecture

Engineering Notes · Design principles and architecture
Permissions are enforced in code, judgment is left to the model
OwlAI's agent reads internal databases and documents, and it also reads web pages and results from connectors…

Engineering Notes · Design principles and architecture
One agent loop for every feature, with the differences in hooks
Five features in OwlAI have a model working by calling tools: chat; playbooks, which run a defined procedure w…