document.
Storing a file is the whole job by default. You can additionally opt an individual file into chunking and embedding with vectorize: true, which makes its text semantically searchable through a paired vector dataspace. That choice is per file, not per dataspace, so one collection can hold manuals worth searching and delivery notes that are not.
→ All modalities
When to use it
Use document when- You have files that describe your equipment and you want them findable by what they describe: asset, model, manufacturer, revision.
- You want somewhere to keep the attachments a record accumulates — a signed PO against an order, a calibration certificate against an instrument — and to hand someone a link to the bytes later.
- You want some of those files searchable by meaning. Pair the dataspace with a vector dataspace and register the files worth searching with
vectorize: true. - The same file is attached to many assets. Documents are content-addressed, so uploading identical bytes twice stores one copy.
The dataspace is the collection; a document is one file in it
This is the distinction most likely to trip you up, because both are named with the word “document” and both have an id.
You do not create a dataspace per file. A project typically has a handful of document dataspaces and thousands of documents inside them.
The document’s key has three spellings
One value, three places, because it is a request field, a URL segment, and a stored column:
Pick something stable and human-meaningful:
hpu-450-service-manual, sop/lockout-tagout, po/2026-00814. Percent-encode it in paths if it contains slashes or spaces.
Re-registering the same key is a new revision of that document, not a second document. Do not key on the file’s hash: that would make every revision a new document and leave the superseded one live and searchable beside the current one, which is the failure mode revisions exist to prevent.
Vectorizing is opt-in
vectorize defaults to false. Register a file without it and the platform stores the bytes and writes the row, and does nothing else: no conversion, no chunking, no embedding. The document’s parse_state is skipped.
That default is about cost, and about what a document dataspace actually holds. Parsing a file costs a format conversion plus one embedding call per chunk — hundreds of provider calls for a 500-page manual. Most files in a document dataspace are attachments nobody will ever ask a question of: a signed PO, a calibration certificate, a photo of a nameplate. Indexing those buys nothing, and a project that uses documents purely as an attachment store never needs a vector dataspace at all.
Set vectorize: true on the files worth searching, and only those:
skipped is a distinct state from pending, deliberately. skipped means nobody asked for this file to be indexed. pending means somebody did and it has not happened yet. Collapsing them would make every stored attachment look exactly like a stalled pipeline, and you would have no way to tell which one you were looking at.Backing store
Document dataspaces are Postgres, using the same keyed-row shape as state: one live row per document, last-write-wins onversion, deletes as tombstones.
The schema
The platform declares the columns it reads itself. You do not passfields for those — anything you send is replaced — and you cannot remove them.
Every column is nullable, because the row is written in stages — it exists before the bytes land.
Your own fields
The built-in columns cover what the platform itself reads. What you need to record alongside a file is yours to decide, and you attach it with ametadata object on the register call:
metadata on every read. There is no special metadata column and no separate metadata store: your keys go into the row like any other field, and the dataspace’s ordinary overflow storage holds the ones that are not built-in columns. That is exactly why document defaults to hybrid — on a typed layout an undeclared column orphans the whole row, so your own field would not merely be untyped, it would take the document with it.
metadata is an envelope rather than loose top-level keys because the register call also carries arguments that are not row columns (sha256, vectorize, multipart). Nesting your fields means a field of yours named multipart can never be mistaken for a platform flag.
Keys must be made of letters, digits, underscores, and dots.
On a typed document dataspace there is no overflow, so metadata comes back empty and undeclared keys are refused. Choose typed only when you have declared every field you intend to send.
The two states
Upload and parse are tracked separately because they are two different things that fail for different reasons.
A document sitting at
awaiting_upload means the bytes never arrived — usually an upload that was abandoned, or a presigned URL that expired before the client got round to using it.
A parse that does not complete leaves the document at
pending, not failed. So pending is the state to watch: a document that has been uploaded for a long time and is still pending is the symptom of a parse that did not run or did not finish, and the usual cause is a missing or wrong content_type at registration. The row is safe to re-drive once that is fixed. List with ?parse_state=pending to find them.Registering a document
The row is written before your bytes exist. A client that vanishes mid-upload leaves a visible row at
awaiting_upload rather than nothing at all.
Content addressing and deduplication
The object key is the file’s SHA-256, so identical bytes are stored once. The same service manual attached to forty assets is one object and forty rows. When the bytes are already present, the response comes back withalready_uploaded: true and no presigned URL. Skip the upload entirely.
Send put_headers verbatim
That is deliberate rather than fussy. The digest and the content type are signed into the URL, so the store itself refuses bytes that do not hash to what you declared and refuses to store the file under a content type you did not declare. It is the store enforcing your claim, not the platform checking afterwards and hoping.
Presigned upload URLs are short-lived — the response tells you how long, in expires_in_seconds. If yours expires, register the document again to mint a new one; re-registering the same key is not a duplicate.
Reading a document dataspace
Three endpoints cover the collection: list it, read one document, and get a link to the bytes.can_read_data on the dataspace. Without it — or if the dataspace is not document — you get a 404, not a 403, so a caller who may not read the dataspace cannot learn that it exists.
List the documents
Newest change first: results are ordered by
updated_at descending, then by key.
total counts everything matching your filters, not just the page, so you can drive a pager from one call.
Read one document
{"item": …} with the same fields as a list row. This is what you poll while a document moves through its two states.
Download the bytes
Authorization header attached. Treat it the way you would treat the bytes: it is a bearer capability to read one object, which is why it expires.
A document whose bytes have not landed has nothing to link to, so this returns 404 while upload_state is still awaiting_upload.
Use
filename. Objects are stored under their content hash, with no extension. Hand the browser the url alone and the download lands as a 64-character hex blob that nothing will open. filename is built from your document key plus the extension implied by content_type — another reason to send one.Document dataspaces also expose the generic row surface (
…/dataspaces/{id}/rows) that state uses, where {row_key} is the document key. Prefer the documents endpoints: they return the derived document_id and your metadata, and they leave out row meta columns that mean nothing to a caller holding a document key.One asset, many documents
This is the grouping the modality exists for, and it is worth being concrete. A u-blox positioning module has a datasheet, a reference design, a set of application notes, and a command reference. That is four separate documents. It is one asset. It is one dataspace.
Register each one with the same
asset_id:
metadata. asset_id, equipment_model, manufacturer, and revision are built in precisely because they are the filters that also have to work on search: they are copied onto every chunk, and a metadata key is not.
End to end: from an API key to a searchable PDF
This is the whole flow for a file you do want to search. It assumes$API, $TOKEN, $ORG_ID, and $PROJECT_ID are set — see the API introduction.
1
Connect an embedding provider
Register an integration account for your organization. Its provider must declare the Keep
embed capability.account_id. The key is sealed in the platform’s secret store and is never readable again.2
Create the vector dataspace
3
Create the document dataspace, paired to it
chunk_dataspace_id is the vector dataspace’s id from the previous step. It must be a vector dataspace in the same project, and it is fixed for the life of this dataspace.chunk_dataspace_id and the dataspace still works as a file store — but vectorize: true is then rejected on every registration, and nothing in it can ever become searchable.4
Register the PDF, asking for it to be indexed
Hash the file first. The API takes hex or base64; the upload header wants base64, and the response gives you both in the right places.If
vectorize: true is the part that matters here. Leave it out and you get a stored PDF and nothing to search.already_uploaded is true, there is no presigned_put_url and no upload to do — jump to the next step.5
Upload the bytes
Send every header from A
put_headers, exactly as returned.403 with a signature message here almost always means a header was changed, dropped, or added by your HTTP client. Many clients set their own Content-Type on a PUT unless you stop them.You do not tell the platform the upload finished. The object store does.6
Wait for it to become searchable
Poll the document. It moves
awaiting_upload → uploaded, then pending → parsed.parse_state: parsed means the chunks have been written. They are searchable by keyword immediately, and semantically once the background job has embedded them — usually a minute or two for one document. See how chunks get their embeddings.If it is still pending long after upload_state reached uploaded, the parse has not completed; check content_type first. If it is skipped, you did not pass vectorize: true.7
Search it
Search the vector dataspace, not the document one.A result carries the
document_id it came from, so you can close the loop: look the document up, then call download to hand the reader the PDF itself.Check mode on the response. If it comes back lexical when you asked for hybrid, a degraded field says why — see when search quietly falls back.Storing a file you will never search
The common case is shorter, needs no vector dataspace, and costs nothing to index. Register the file, upload it, hand out a link.1
Register it without vectorize
presigned_put_url exactly as above. When the object store reports them, upload_state becomes uploaded and parse_state stays skipped — final, and correct. Nothing was parsed, chunked, or embedded, and no provider call was made.2
Hand someone the file
url behind a button in your own UI and save it as filename. The key contains a slash, so it is percent-encoded as po%2F2026-00814 in the path — and dropped from the suggested filename, which has no directories to make.?asset_id=. To audit what you have never indexed, list with ?parse_state=skipped.
Loading a revision
To publish a new edition of a document you already have, register the same key with the new file’s hash, a newrevision, and a new effective_from. The document row updates in place, the new bytes are uploaded, and if the document is registered with vectorize: true its chunks are overwritten rather than added to.
Then pass revision on your searches so a superseded procedure cannot surface as a current one. effective_at alone will not do it — it hides revisions that are not yet in force, not ones that have been replaced. See filters.
If that matters — a procedure was removed rather than reworded, say — clear the document’s chunks before re-registering it. Tombstone them by document_id on the vector dataspace, then register the new edition:
Create
chunk_dataspace_id for a pure file store. Add "layout": "typed" if you want undeclared metadata keys refused instead of absorbed.
$API is the platform base URL (production: https://api.ilyama.golain.io/core/api/v1).
Related
- Vector dataspaces — the chunks, and how search works
- Dataspaces overview
- State — the keyed-row shape document dataspaces share
- Schema and layouts — what
hybridandtypedmean - API introduction