Skip to main content
Metadata is structured data attached to each knowledge source and memory. You send it at ingest, and at query time metadata_filters on POST /query keeps only the sources whose values match, such as department = legal or status = published, before ranking runs. In this guide:
  1. Declare a field and filter on it: create the schema, ingest with metadata, and query with a filter.
  2. The two layers: metadata and additional_metadata, and how a filter runs.
  3. Filter operators: equals, contains, and contains_any, and how to store several values in one field.
  4. Size limits: caps on stored metadata and on the filter you send.
  5. Add schema fields later.
  6. Update metadata without re-ingesting.
  7. List sources with metadata filters.
  8. Schema field reference, common mistakes, and related pages.

1. Declare a field and filter on it

Three requests: create the database with a metadata schema, ingest a source that carries values for those fields, then query with metadata_filters. The example declares department, priority, and a summary_label field that is also searchable as text.
Set up your SDK client before running the Python or TypeScript example.
After creating the database, wait for GET /databases/status to report infra.ready_for_ingestion: true before ingesting.Send metadata and additional_metadata on each app_knowledge[] item, or on each document_metadata[] item when you upload files. For type=memory, the memories field is already a JSON string, and each memory item’s metadata and additional_metadata are plain objects inside it; do not stringify them a second time.
Wait for GET /context/status to report completed for auth-controls-001 in the default collection before running this query.Top-level keys filter on metadata. Keys nested under additional_metadata filter on the free-form layer. Both can appear in one request.

What each property means

A valid filter that matches no source returns an empty result. HydraDB does not widen it into an unfiltered search.

2. The two layers

A filter runs in three steps:
  1. HydraDB resolves the metadata and additional_metadata filters to the matching source IDs.
  2. It runs vector and keyword retrieval only over those sources.
  3. It rechecks every retrieved passage against the filter. The recheck also keeps graph expansion and retry paths from leaking excluded sources.
An additional_metadata filter that cannot be resolved up front is checked after retrieval, with about 3x over-fetch to compensate. A few fields worth declaring from the start:
  • status: tag every source draft or published, and send {"status": {"equals": "published"}} on user-facing queries to keep work in progress out of customer answers.
  • language: route each query to its language with a filter on the detected language.
For a company brain, filters do the isolation inside one collection: put a team, region, or user id in metadata and filter on it in each query. Use separate collections only for data that must never meet in one search.

3. Filter operators

Each top-level key in metadata_filters takes an operator object naming the comparison: equals works on every declared type, so {"priority": {"equals": 7}} is valid on an INT64 field. contains, contains_any, and arrays need a VARCHAR field; sent for a declared field of another type, they are rejected with 400.
Given four sources with these attendee_emails values:
Filtering additional_metadata: operators apply to top-level keys only. Inside additional_metadata, use a bare value for an exact match or an array to match any listed value. An operator placed there is not rejected; it is read as an exact match against a stored object, so the query returns 200 with an empty result. What returns 400, and what does not: a known operator with the wrong value type, several operators in one object, or a null value is rejected with 400. A misspelled operator, such as {"contian": "x"}, is not rejected; it is read as a filter for a stored object with that key and matches nothing. Reserved key names: equals, contains, and contains_any are read as operators. This only matters for a JSON field that stores an object whose keys are all drawn from those words: {"contains": "x"} is read as the operator, {"contains": "a", "equals": "b"} is rejected with 400, and {"contains": "a", "other": 1} stays an ordinary exact-match filter. To match such an object, rename the nested key or the field. Bare forms: filters without an operator still work but are deprecated, because the comparison is inferred from the JSON shape. A bare value behaves as equals, a bare array as contains_any, and a one-element array as contains, so {"emails": "a@x"} and {"emails": ["a@x"]} return different results. document_metadata is still accepted as a legacy alias for additional_metadata; when both are present, additional_metadata wins on conflicts.

Store several values in one field

A declared schema field holds a single value. To store several values in one field, declare it as VARCHAR and join the values with commas, then filter for one member with contains.
At ingest:
At query:
  • data_type: "array" is not accepted on a declared field: declaring one is rejected with 400.
  • An array value for a declared VARCHAR field is also rejected: metadata field "attendee_emails" must be of type string, got array. Join the values yourself.
  • The comma is the separator: a value that itself contains a comma will not match as expected. Use a field per value, or a different value format, if your values can contain commas.
  • Size the field for the whole joined string: max_length defaults to 1024, its maximum is 65535, and it cannot be raised after the field is created, so declare it large enough up front.
  • equals compares the entire joined string: it is rarely what you want on a multi-value field. Use contains.

4. Size limits

Stored metadata

Every request that attaches metadata is checked against two caps, on ingest and on metadata edit alike: Older spellings are still accepted, but which one works depends on the endpoint: Accepted aliases have the same cap as the canonical field. The cap applies to the whole map, not to any one value, and it is measured on the map’s compact JSON encoding in UTF-8 bytes. Three consequences:
  • Keys and punctuation count: quotes, colons, commas, and braces all count toward the byte limit.
  • Bytes, not characters: accented Latin characters cost 2 bytes, most CJK characters 3, and emoji 4.
  • Budget in bytes from the start: a 950-character summary sounds comfortably under a 1 KiB cap, but with two small sibling keys it serializes to 1,015 bytes (65 bytes of that is structure alone). Push the summary to 1,000 characters and the request is rejected at 1,065 bytes.
Exceeding either cap fails the whole request with 400 before anything is ingested. The message names the offending field and reports both numbers. On PATCH /context/{id}/metadata the same message is prefixed with invalid metadata edit:.
Document metadata: 1,015 bytes, just inside the 1 KiB cap
If a document needs more than 1 KiB of descriptive metadata, put the long text in the document body, where it gets chunked and embedded, and keep additional_metadata for the short values you filter on.

Filter size limits

metadata_filters on /query has its own pair of caps. They bound what you send at query time, not how much metadata a source carries: Both are measured the same way as the stored caps: compact JSON, UTF-8 bytes, field names and punctuation counted. The object total includes the nested additional_metadata object, and it is measured after operator objects are reduced to their values, so {"contains": "x"} counts as ["x"] and the operator keyword costs nothing. Both limits exist because every value in a list is expanded into the filter expression sent to the vector store. The per-list cap catches one runaway list; the object cap catches many individually legal lists adding up. Twenty lists of 500 values are each within the element cap but total roughly 127 KiB, so the object cap is what rejects them. Exceeding either limit returns 400 before the query runs, naming the offending key or the actual byte count.
Needing far more than 500 values in one filter usually means the constraint belongs in the data rather than the query: add a metadata field that groups those values (a segment, tier, or cohort key) and filter on that instead.

5. Add schema fields later

PATCH /databases/{database}/metadata-schema adds fields to an existing database. It is additive only: fields are added, never deleted, retyped, or reflagged. The response returns added_fields directly, without the success and data envelope, and a field you resend with an identical definition is skipped.
Plan the schema before your first ingest:
  • Searchable fields are declared at creation: fields added later cannot turn on dense or sparse search; the endpoint rejects enable_dense_embedding and enable_sparse_embedding.
  • Existing fields are fixed: renaming a key means re-ingesting the affected sources, or creating a new database with the corrected schema.
  • Undeclared keys match nothing: a database with a schema rejects undeclared metadata keys on ingest and on edit, so no source ever holds them, and a filter on such a key returns nothing. The exceptions are connector_id and provider, which connectors write on every synced object. If you will filter on a field more than once, declare it.

6. Update metadata without re-ingesting

Use PATCH /context/{id}/metadata when you know the source ID and need to change its metadata in place. The body uses database_metadata for the schema-backed layer (the shorter metadata spelling from ingest is not accepted here) and additional_metadata for the free-form layer.
  • The source must already exist: this endpoint does not create sources, and collection is required.
  • At least one of database_metadata, additional_metadata, or acl is required: tenant_metadata is a deprecated alias for database_metadata; document_metadata is rejected.
  • Metadata merges: sent keys are inserted or overwritten; omitted keys are preserved. acl is the exception: when sent, it replaces the stored list, and an acl-only body is a valid edit. See Access Control.
  • The edit is visible to filters and listing right away: if no edited field has an embedding flag, the vector store is untouched and the response reports vector_sync_required: false.
  • Searchable fields sync synchronously: if an edited field has enable_dense_embedding or enable_sparse_embedding, HydraDB refreshes that field’s search lane and reports vector_sync_required and vector_synced in the response (the milvus_sync_required and milvus_synced aliases are still emitted, deprecated). Such a field rejects null, because it would leave stale metadata vectors behind.
To replace a source’s content as well as its metadata, re-ingest it with upsert: true and the same source id. Upsert replaces the source payload and the metadata supplied by ingestion.

7. List sources with metadata filters

Use POST /context/list to browse or page sources rather than run semantic retrieval. Its filters object takes metadata, additional_metadata, and source_fields, each as bare key-value pairs. source_fields filters on the source’s own fields: title, type, description, url, timestamp, and for app sources app_provider, app_kind, app_external_id, and app_parent_id. Pair app_external_id or app_parent_id with app_provider, because a Jira issue key and a Linear ID can collide.
/context/list also accepts the legacy aliases tenant_metadata for metadata and document_metadata for additional_metadata; the current name wins on conflicts. It takes the same acl field as /query to list as one person. See Access Control.

Schema field reference

Each entry in database_metadata_schema on Create Database, and in add_fields on Update Metadata Schema, takes these options: Limits:
  • Up to 32 custom metadata fields per database.
  • Up to 6 embedding-enabled fields per database: enable_dense_embedding and enable_sparse_embedding each count as one, so a field with both set counts as two. Exceeding the limit fails database creation with 400 before anything is provisioned.
  • Field names are unique case-insensitively.
  • Embedding flags are only valid on VARCHAR fields.
  • Values must match the declared type when the database has a schema.
  • Unknown metadata keys are rejected on ingest and edit when the database has a non-empty schema.
  • Each metadata layer has a byte budget per request; see Size limits.

Common mistakes