Get started

Engineering NotesMemory and search

To total a table in a document, load it into a temporary SQL table

Upload a Hangul document with a 300-row price list and ask for "the average unit price by product group," and document search (RAG) returns a few chunks that look like the question. Documents are indexed in 4,000-character pieces, so the table is split across several chunks, and the header that says which column is which sits only in the first one. The model averages the rows in the chunks it received and presents the result as the average for the whole table. The number is wrong, but the answer looks fine.

So when a table inside an uploaded document needs aggregation, we load that table into a temporary table visible only to the conversation and count it with SQL. The server reads the rows from the file itself, and the server decides the header and column types. This post covers why search and file reading don't produce correct totals, where we put the rules that turn a table into a database table, and where temporary tables live and which execution path they take. Counting data that already lives in a database is covered in “RAG can't count or sum rows, so we ask the source database in SQL”.

Indexing text separates a table from its header

For document search to find a table, the table has to be flattened into text and indexed. When we unpacked four kinds of Hangul (HWPX) documents, table cell text made up 30–45% of all the text in the document. Leave tables unflattened and that share drops out of search. So the parser flattens each table into text: one line per row, cells separated by tabs.

A flattened table is searchable, but it loses the structure aggregation needs. What it loses differs a little by format:

  • The header gets separated. Documents are cut into fixed windows by character count (document.chunkSize, 4,000 characters by default, with document.chunkOverlap at 500). The second chunk of a long table holds only rows like "A bolt 3200," while the header that says which column 3200 belongs to sits in the first chunk.
  • The table isn't where it belongs. The indexing text for Word and Hangul documents writes out every body paragraph first and appends the table rows after them. A sentence like "Unit prices by item are as follows" ends up far from its table in the index.
  • Cell boundaries disappear. PDF extraction pulls page text only and doesn't recognize tables separately. For Hangul binary (HWP) files, the library that reads them flattens tables into plain text, leaving no cell boundaries.

Search picks chunks that resemble the question; it doesn't gather every row that matches a condition. An average needs every row of the table, together with its header, and the index guarantees neither.

One price list as header-less index chunks and as a temporary table counted with SQL
Figure 1. One price list as header-less index chunks and as a temporary table counted with SQL

When the model counts the rows it read, nobody can check the total

The tool that reads attached files doesn't go through the index. It returns tables from Word, Hangul, and Excel files as arrays of rows and cells, so the mapping between header and value holds. Still, having the model count that array itself goes wrong in two places.

First, nothing guarantees the rows the model received are the whole table. When one result is larger than the context can hold, the server reduces it and tells the model it did so (“Shrink giant tool results before the provider cuts them, and say so”). When a long conversation gets compacted, older read results keep only the number and position of tables and drop the cells. Hundreds of rows can't stay in context until the next round.

Second, a total is a calculation that must give the same value for the same table every time. If the model reads a few hundred numbers and adds them up, there is no way to check the result. What code can count, code counts.

A code path exists today. The sandbox includes Word, Hangul, and Excel document libraries, so the model can reopen the attached original and count with Python. For counting one file once, that path is enough. But the script interprets the table anew for every question. Each script decides again whether the first row is the header and whether 1,234 is a number, so asking twice about the same table can count it by two different rules. For a question that matches an attached results spreadsheet against the company database's orders by customer code, the script has to pair the database result file with the attachment by hand, for a job one SQL statement can do.

The rules for turning a table into a database table live on the server

Loading is done by a table-loading tool. The model names only the attachment and the table's position: a sheet for Excel, a table number for Word, Hangul, or PowerPoint. The server reads that table with the same parser the file read tool uses and creates a table from it. Because the model doesn't rewrite rows as arguments, no value changes in transcription, and however large the table, the arguments are a few coordinates.

The server decides the table's shape by fixed rules:

What is decided Rule
Column names The table's first row. Word and Hangul tables carry no header marker, so the first row is read as the header
Numeric columns A column becomes numeric only when every value in it keeps its notation after conversion to a number
Text columns If even one value would change notation as a number, like 007, 1,234, or 3%, the column stays text

Turning 007 into a number drops the leading zero from an item code. So the table stores the original notation as is, and for values that must be counted as numbers, such as amounts with commas, the model converts them in SQL. If stored values differed from the original, results couldn't be checked against it.

Documents contain more forms—applications, approval boxes, spec sheets—than data tables. Load a form as a table and an input label like "Organization name" becomes a column name while empty input cells become rows. So the server refuses to load a table, and returns the reason, if any of these applies:

  • It has fewer than 3 columns. Two-column tables in documents are usually "item | value" forms.
  • The header has an empty cell.
  • A header repeats. That's a trace of the parser spreading a horizontally merged cell across each column.
  • Rows have different numbers of cells. That's a trace of a merged cell loaded as a single cell.

With these rules on the server, the same table becomes the same database table whenever it's loaded.

Temporary tables live in a working schema created for each conversation

Temporary tables live in a working schema created for each conversation. The company database is attached only through a read-only account (“Read-only and query cancellation have to reach the database server”), so the working schema can't be created there. It sits in OwlAI's own database, and company database query results can be loaded into the same schema. SQL execution results are kept as CSV spill files (“Query results go to a file so the model never retypes them”), and the table-loading tool takes that file path and builds a table from it. With the attached spreadsheet and the order query results side by side in one schema, joining on customer code and counting the differences takes one SQL statement.

Queries go through the same read-only execution tool as the company database, with the same statement checks, execution time budget, result row cap, and audit logging. The account attached to the working schema can see only that conversation's schema, so it can't reach other conversations' temporary tables. The working schema belongs to its conversation, so it doesn't appear in the admin's database connection list. Results go out like any other SQL result, as a table in the answer and a spill file.

A temporary table is a workspace, not the original. The original file stays in object storage (“Files that must survive a redeploy live only in object storage”), and the table can be rebuilt from it at any time. So we gave temporary tables the same lifetime as spill files. If an old conversation asks again after the table has expired and been deleted, the model reloads the table from the original. Deleting a conversation deletes its working schema too.

Design choices

The indexing text keeps appending tables after the body. Flattening tables in document order and cutting chunks along table boundaries would separate headers less often and make searches for a single number more accurate. But it would mean reindexing every existing document, and search still wouldn't return every row of a table. Since aggregation goes through temporary tables, we kept the current flattening for the index.

Form detection looks only at a table's shape. A form with 3 or more columns, a fully filled header, and even cell counts gets loaded as a table, and a data table with merged cells is refused. A refused table can still be counted by opening the original directly in the sandbox. Adding conditions could sort out edge cases like these, but the more conditions there are, the harder it is to predict which way a given table will go. So conditions change only after rechecking against real form documents.

A temporary table belongs to one conversation. It isn't shared with other conversations or other people, and if the original file changes, a table already loaded doesn't change with it. For a table you ask about once, that scope is enough. If several people ask about the same results sheet every month, move that table into a database, connect it, and run schema analysis.