Skip to content

Metadata & filters

Tag your documents, then narrow a search to the ones that matter.

A collection can declare typed fields — a department, an effective date, a set of tags — and every document in it can carry those, plus any extra labels you invent on the spot. Agents can filter on the declared ones; you can filter on both.

Declared fields are the vocabulary. They are typed and validated, they appear in dropdowns and facet counts, they support ranges and set operators, and — this is the part that matters for agents — an assistant’s search tool is told they exist. A model cannot filter on a field it has never heard of.

Extras are anything else: a plain key and a string value, accepted with no ceremony. Use them when you want to label something now and decide whether it deserves a schema later. Extras are stored, shown and filterable by exact match, but they are not advertised to agents and they do not support ranges.

The short version: declare a field when an agent should be able to filter on it; use an extra when it is just a label.

In the console, open the collection’s Settings tab and use Metadata fields: Add field, give it a name (lowercase letters, digits and underscores, starting with a letter), pick a type, and for a One of a list field enter its options one per line. Add a description if the name alone would leave a model guessing — it is shown to the model as guidance, both to agents choosing filters and to auto-metadata proposing values. Nothing is stored until you press Save settings, together with the rest of the tab.

Required means every upload, bulk import and metadata edit must supply a value or it is refused. Uploads from the console’s own upload dialog carry no metadata, so on a collection with a required field they are refused too — use the API or a bulk import with a metadata sidecar. Connector syncs are exempt, and documents already stored are not re-checked.

Once saved, a field’s name and type are fixed. Its description, options and required flag stay editable. Changing the type or the name would not touch the values documents already carry, so a field would end up holding values of the wrong type, and a range filter on it would fail. For a different name or type, add a new field under a new name; documents keep their values under the old one until they are edited.

Removing a field deletes nothing from your documents. They keep their values, which become undeclared extras: agents are no longer told about them, they have no facet counts, and they filter by exact match only. An extra must be text, so a document still holding a number, yes/no or tags value for a removed field cannot have its metadata edited until you delete that value or re-enter it as text. Taking an option away from a One of a list field works the same way: documents that carry it keep it, and their next metadata edit must pick a remaining option. Pending auto-metadata proposals for a removed number, yes/no or tags field can no longer be accepted, since accepting checks the value against the current schema.

Adding the field back under the same name declares those values again, so it must come back with the same type — the console refuses a different one, and re-adding it in the same edit carries its saved description, options and settings over. If Ranking uses the field for recency, choose another recency source there before saving; the console will not save a recency source that is no longer a date field.

The same schema can be set through the API, as a whole list — a PATCH replaces every field, so send the ones you want to keep as well:

Terminal window
curl -s -X PATCH "$KOVAC_BASE/api/manage/collections/$COLLECTION_ID" \
-H "Authorization: Bearer $KOVAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata_schema": [
{"name": "department", "type": "enum", "options": ["HR", "Finance", "Legal"],
"description": "Owning department", "required": true},
{"name": "effective_date", "type": "date"},
{"name": "tags", "type": "tags"}
]
}'
Type Values Operators beyond equality
string any text —
enum one of options in
number a number gt gte lt lte
date YYYY-MM-DD gt gte lt lte
boolean true / false —
tags a list of strings any

The API also accepts "embed": true on a field, meant to fold its value into the text that gets embedded. It is stored and kept on every save, but nothing applies it yet, so the console does not offer it.

Reserved names cannot be declared or set: page, page_end, section, section_path, order, title, filename, mime_type, page_count, author, created, modified, language, code_language, uploaded_by, folders. These come from the document itself or from who you are signed in as, and anything you set would be overwritten on the next ingest. language is the natural language a chunk is written in (see the Collections page); a source-code file’s programming language is code_language. folders is derived from a document’s path; see Organise documents in folders.

At upload, as a JSON field alongside the file:

Terminal window
curl -s -X POST "$KOVAC_BASE/api/manage/collections/$COLLECTION_ID/documents" \
-H "Authorization: Bearer $KOVAC_API_KEY" \
-F "file=@handbook.pdf" \
-F 'metadata={"department": "HR", "effective_date": "2025-04-01", "cost_centre": "CC-12"}'

cost_centre was never declared — that is an extra, and it is fine.

Afterwards, on one document or a batch:

Terminal window
curl -s -X PATCH ".../documents/$DOC_ID" -d '{"metadata": {"department": "Finance"}}'
curl -s -X PATCH ".../documents:batch" \
-d '{"document_ids": ["...", "..."], "metadata": {"department": "HR"}}'

Metadata replaces rather than merges, so send the whole object. Editing metadata never re-embeds anything: the text has not changed, only the labels on it.

Filling declared fields by hand for hundreds of documents is what nobody does. Turn on auto-metadata and a model reads the first ~8,000 tokens of each new or re-parsed document and proposes a value for every declared field that has none:

Terminal window
curl -s -X PATCH "$KOVAC_BASE/api/manage/collections/$COLLECTION_ID" \
-H "Authorization: Bearer $KOVAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"extract_metadata": true}'

Three things are true by construction. A field that already has a value — set at upload, by a sidecar, by a connector or by hand — is never asked about, so the model never writes over anything you set. Every proposed value passes the same validation a hand-set value does, so an enum value outside its options is never written; a value that fails is dropped and named in the ingestion attempt’s warnings. And nothing is applied without you, unless you say so per field with auto_apply — this field’s proposal is applied as soon as the model is at least 80% sure of it:

{"name": "document_type", "type": "enum", "auto_apply": true,
"options": ["policy", "contract", "manual", "invoice", "slide deck",
"report", "correspondence", "other"]}

Proposals wait on the document (metadata_proposed on GET …/documents/{id}, or the Metadata tab in the UI). Accept or reject them by name or all at once, and list the documents that still have some:

Terminal window
curl -s -X POST ".../documents/$DOC_ID/metadata-proposals/accept" -d '{"fields": ["department"]}'
curl -s -X POST ".../documents/$DOC_ID/metadata-proposals/reject" -d '{"fields": "all"}'
curl -s ".../collections/$COLLECTION_ID/documents?has_proposals=true"

Accepting re-checks the value against the schema as it is now, writes it onto the document and its chunks without re-embedding, and replaces any value the field has picked up in the meantime — you asked for this one. Setting a field by hand clears its pending proposal. A rejected value is remembered and not proposed again (for a tags field, the same tags in any order count as the same value).

Your decisions are the only labels there are: GET …/collections/{id}/metadata-schema/stats reports, per field, how many proposals are pending, accepted, rejected or auto-applied, and accepted-over-decided as accuracy — null until you have decided any.

Classification and “who is mentioned” need nothing extra: declare an enum field (document_type with the options above is a good start) and a tags field (entities, say), and the same call fills them. The call runs once per document and is billed to the tenant’s ingestion usage; each ingestion attempt’s llm_steps carries it as metadata_extraction. If the call fails or the provider refuses it, the document still indexes, its earlier proposals stay as they were, and the attempt’s warnings say which. It runs on ingest and on re-parse — not on re-chunk or re-index, which never re-read the text.

The same filter object works for search, for search preview, and for listing documents:

{
"department": "HR",
"effective_date": {"gte": "2025-01-01", "lt": "2026-01-01"},
"region": {"in": ["EU", "UK"]},
"tags": {"any": ["policy"]},
"cost_centre": "CC-12"
}

To find documents that are missing a field, or that have one, use exists:

{"category": {"exists": false}}

{"exists": true} matches documents where the field holds a value. {"exists": false} matches documents where it is absent, empty ("" or an empty list) or null. It works on declared fields and on extras, and takes only true or false. Agents are told which fields support it, but inferred filters never use it. A workflow can use it to find the documents that still need a field filled in, see the documents step.

All conditions must hold — there is no OR yet. A range on an undeclared extra is rejected rather than guessed at: without a declared type there is nothing to order by, and comparing strings that were never claimed to be dates would quietly return the wrong rows.

Terminal window
curl -s -G "$KOVAC_BASE/api/manage/collections/$COLLECTION_ID/documents" \
--data-urlencode 'filters={"department":"HR"}'

An agent does not have to name a filter for one to apply. Turn on Infer filters from questions on the collection’s Settings tab and the query planner reads the question itself (“the HR parental leave policy from 2025”) and proposes a filter over this collection’s own declared fields — the same ones an agent’s search tool is told about.

Only typed fields are inferred — enum, date, number, boolean and tags. A Text field (string with no options) is never offered to the planner: the model would have to guess the exact stored spelling, and a wrong guess matches nothing while still costing the fallback search above. Every typed field has its own May be inferred checkbox, on by default once the collection switch is on, so a field you never want guessed at can be left out; a Text field shows a muted note instead of a checkbox — make it a list (enum) to make it inferable.

Three things are true by construction:

  • An explicit filter always wins. When the agent (or a search-preview request) supplies its own filters, nothing is inferred for that call — there is no second-guessing a filter someone actually asked for.
  • A proposed entry is validated, not trusted. Every entry the model proposes goes through the same filter grammar a hand-written one does — an undeclared field, a field not marked inferable, an enum value outside its options, or an operator that does not fit the field’s type is dropped rather than applied. A dropped entry can only narrow what gets searched less than the model intended; it can never widen a search past what the grammar already allows.
  • An over-narrow filter falls back, it never empties the answer. If the filtered search finds nothing, or finds only weak matches, the search runs again with the same sub-questions and the inferred filter removed — not a broader rewrite of the question, since the filter, not the wording, was the problem. The unfiltered results stand whatever they score.

This costs no extra model call: the planner already asks a model to split the question into sub-queries, and the filter rides the same reply when the collection has opted in.

Seeing it. Every search’s trace carries a retrieval.query_plan observation: whether inference ran at all, the sub-queries used, every filter entry the model proposed, what was dropped and why, what was actually applied per collection, and — when the inferred filter was removed on retry — that it was. Search preview shows the same thing as an Inferred filter panel next to the results, with a button to re-run the same question without the inferred filter for comparison.

Value counts for a declared field, for building a filter UI:

Terminal window
curl -s "$KOVAC_BASE/api/manage/collections/$COLLECTION_ID/facets?field=department"
# {"field": "department", "values": [{"value": "HR", "count": 12}, ...]}

Declared fields only — a facet list is a vocabulary, which is what declaring a field is for.

An assistant attached to a collection gets its declared fields listed in the search tool description: names, types, enum options, your own field descriptions and which operators each supports. Extras are not listed, so an agent will not invent them.

That is the practical reason to declare a field rather than leave it an extra: an agent can only filter on what it has been told exists.

Uploading a CSV, spreadsheet or JSON export in records mode adds a typed field to this collection’s schema for every column you gave a metadata or both role — see Collections for the roles themselves.

The type comes from the values: all numbers makes a number, all dates a date, a small repeated vocabulary an enum with those options, anything else a string.

Two things it never does. It never marks a field required, so a later upload missing that column still succeeds. And it never changes a field you already declared — if this collection says priority is a string and a spreadsheet arrives whose priority column looks numeric, the values are validated as strings. Change the type yourself if that is what you want.