Models are good at writing SQL. Why rely on similarity searches, when you can let it ask for exactly what it needs?
Ingot is a drop-in memory for an agent loop. Tool results can clog up your context window. Ingot gives you a dedicated place to store and retrieve them efficiently.
Some tools return payloads big enough to overwhelm your context window. Cast them to an ingot and the model only sees a concise receipt, so the context window stays manageable.
Naive RAG can give imprecise results, because it relies on approximate similarity search. SQL gives exact answers, so the model retrieves the correct data every time.
33 questions across 6 task classes with 3 runs each.
No embedding step, and no re-reading the transcript. The tool result immediately becomes a table the model can select from.
# 1 — remember a tool result POST /api/v1/acme/ing_01H8Z…/add { "table": "contacts", "rows": "$.contacts[*]", "columns": { "id": { "from": "$.id", "type": "VARCHAR" }, "company": { "from": "$.org.name", "type": "VARCHAR" }, "arr": { "from": "$.deal.arr", "type": "DOUBLE" } }, "key": ["id"], "result": toolResult } 201 Created · rowsAdded 412
# 2 — read it back, the same second POST /api/v1/acme/ing_01H8Z…/query { "sql": "SELECT company, arr FROM contacts WHERE stage = 'won' ORDER BY arr DESC" } 200 OK · 34ms { "columns": ["company", "arr"], "rows": [ { "company": "Northwind", "arr": 184000 }, { "company": "Contoso", "arr": 96500 } ], "truncated": false }
For teams building their own agent harness for production apps, where the tools return real records and the answers have to be right.
Ingot still does similarity search, so a mix of both is fine. See how they combine.
“RAG” can encompase complex document processing pipelines, but Ingot focuses solely on the storage and retrieval aspect, leaving the rest to other components.
The same rows through top-k alone, measured →| The job | A full RAG stack | Ingot |
|---|---|---|
| What it replaces | ||
| Chunk documents | A loader and a splitter in front of the store. | /file chunks per format, and can pull typed rows out of the same file. |
| Embed | A pipeline writing vectors into another system. | Handled automatically, via an opt-in per table/tool result type |
| Store vectors | A vector database beside your data. | The same tables as the rows. No second database. |
| Retrieve | top_k(embedding). | SQL, with cosine and BM25 as ranking functions inside it. |
| Count, aggregate, sort by time | No nearest-neighbour formulation. | A GROUP BY and an ORDER BY. |
| Join sources | One index per query. | One SELECT across tables — chunks, files and typed rows together. |
| What goes in | Documents. | Tool results, documents or external data you want to pre-populate the table with. |
| What it leaves out | ||
| Rerank and rewrite | A reranker, often a query rewriter. | Neither. Hybrid means the SQL you wrote ranks on BM25 and cosine together. |
| ANN index | HNSW or similar. | Currently none. Cosine similarity is a good trade-off until the low millions of rows per table. |
| Schema | None asked for. | Defined dynamically when adding a memory. It can be inferred or declared explicitly. |
First create an ingot, then add memories to it.
Its will then be available to query.
One POST. Name it, say how long it lives — 30m for a session, 4w for a project — and keep the id it hands back.
POST /:account/castMap JSON paths onto typed columns and fan an array into rows. Set a key if you want upserts. Ask for a receipt and you get something small enough to hand back to the agent.
POST /:account/:ingot/addOne SELECT, against a sandboxed DuckDB that unions the fresh rows with the Parquet. Or skip the SQL and ask in words.
POST /:account/:ingot/queryUnchanged. The model asks for a tool, your harness runs it, and nothing about that hop knows Ingot exists.
toolCallId: call_01H8Z…One POST, before you return. Send the tool-call id along as externalId and you can ask for this receipt back later by a name that means something to you.
POST /:ingot/addHow many rows, which table, and a SELECT that returns exactly them. Return that as the tool output — it goes in the slot the blob would have filled.
201 · 412 rowsNarrowed over typed columns, in this step or in a session next week. Twenty rows out of four hundred, picked by the model — not by whoever wrote the tool six months ago.
POST /:ingot/queryThere is no adapter here, and no middleware. execute already returns whatever the model is going to read, so have it return the receipt instead of the rows — and the rest of your harness, the stream and the parts and the steps, never finds out anything changed.
// tool.ts — AI SDK 5. // post() is fetch with the bearer key on it. import { tool } from 'ai'; import { z } from 'zod'; const INGOT = 'http://localhost:3002/api/v1/acme'; const ingot = INGOT + '/ing_01H8Z…'; export const searchContacts = tool({ description: 'Search the CRM by stage.', inputSchema: z.object({ stage: z.string() }), async execute({ stage }, { toolCallId }) { const result = await crm.contacts.search({ stage }); const { receipt } = await post(ingot + '/add', { table: 'contacts', rows: '$.contacts[*]', columns: { id: { from: '$.id', type: 'VARCHAR' }, company: { from: '$.org.name', type: 'VARCHAR' }, arr: { from: '$.deal.arr', type: 'DOUBLE' } }, key: ['id'], externalId: toolCallId, receipt: 'full', result, }); // The 412 contacts stay in the ingot. This // is what goes back in their place. return { rows: receipt.totalResults, table: receipt.table.name, query: receipt.query, }; }, });
# the tool-result part, as the model reads it { "rows": 412, "table": "contacts", "query": "SELECT * FROM contacts WHERE source_batch = 'batch_1508c8…'" } # about 180 tokens. The result it stands in # for was 412 objects and about 48,000. # the browser is handed the same small object, # as the stream's tool-output-available part — # it is one JSON either way, and this one fits # next step — the model narrows it itself POST /api/v1/acme/ing_01H8Z…/query { "sql": "SELECT company, arr FROM contacts WHERE source_batch = 'batch_1508c8…' AND arr > 100000 ORDER BY arr DESC LIMIT 20" } 200 OK · 31ms { "columns": ["company", "arr"], "rows": [{ "company": "Northwind", "arr": 184000 }, …], "truncated": false }
/add returns the moment the rows land. The summary is written behind it and the receipt says pending, so your tool result is never sitting there waiting on a second model to finish a sentence.
receipt.status: pendingYou will need to include the query tool for this to really be useful.
tools: { query: ingot.tools.query }Nothing headed for the interface has to go through the context window. Return the full result from execute and hand the model the receipt from toModelOutput. One gets streamed, the other gets read.
toModelOutput()# opt in: per table, and per call POST /api/v1/acme/ing_01H8Z…/add { "table": "notes", "rows": "$.notes[*]", "columns": { "body": { "from": "$.body", "type": "VARCHAR", "embed": true } }, "receipt": "full", "result": toolResult } 201 Created · queuedForEmbedding 412 { "receipt": { "status": "pending", "model": "gpt-4.1-mini", "summary": null, "searchTerm": null, "receiptQuery": "SELECT … WHERE source_batch = 'batch_1508c8…'" } } # seconds later, that query answers { "summary": "412 call notes, 17 flagging renewal risk in EMEA", "search_term": "EMEA renewal risk" }
We split these two on purpose. embed belongs to the table: set it once when the table is declared and it applies to every write after that. receipt is per call, because it costs a model call every time. A loop storing ten thousand tool results should never end up paying for either by accident.
A receipt comes back pending, with the SELECT that will answer it. The model writing the summary is a network away; your rows are queryable the instant /add returns. Point the ingot at a webhook or a queue and you get told instead of having to ask.
text on its own embeds the question and ranks a table by cosine similarity, so rows come back carrying a score. SQL on its own is exact. Send both and the embedding binds as $q, so one SELECT can rank by meaning, match BM25 and filter on real columns at the same time.
That is normally three pieces of infrastructure: a vector store, a metadata index, and a filtering hop between them. We did not want to run any of those, so here it is one POST against the ingot you were already writing to. Neither half is on by default — you turn on embeddings and the keyword index per table, so a table holding no prose pays for neither.
# once — switch keyword indexing on POST /api/v1/acme/ing_01H8Z…/config/notes { "fts": { "enabled": true } } # ask by meaning. Rows come back scored. POST /api/v1/acme/ing_01H8Z…/query { "text": "renewal risk in EMEA", "table": "notes", "column": "body" } 200 OK { "columns": ["id", "body", "region", "score"], "rows": [{ "score": 0.83, … }] } # or all three at once — one round trip { "text": "renewal risk in EMEA", "sql": "SELECT body, region, array_cosine_similarity(body_vec, $q) AS near, fts_main_notes.match_bm25(_row_id, 'renewal') AS words FROM notes WHERE region = 'EMEA' AND created > '2026-01-01' ORDER BY near DESC LIMIT 8" }
Host it yourself! Check out the deployment guide in the documentation.