Skip to main content
Metadata is structured data attached to Knowledge and Memories. Use it when you already know a hard constraint before retrieval runs, such as department=legal, region=us, status=published, or author=alice. HydraDB has two metadata layers: If a field will be scoped on every query, it belongs in metadata and the database schema. If it’s ad-hoc or unique to one document, use additional_metadata instead.

1. Choose the right metadata layer

The database field was formerly tenant_id and collection was formerly sub_tenant_id; the old names still work as deprecated aliases. Follow this for when to use database and collection.

2. How metadata filters run

For user-provided metadata_filters, HydraDB uses a correctness-first pipeline:
  1. MongoDB source-id prefilter. Safe scalar tenant/additional metadata filters are resolved to matching source_ids in MongoDB.
  2. Scoped retrieval. Vector/BM25 retrieval searches only those source IDs.
  3. Post-filter correctness net. Hydrated chunks are checked again against the requested metadata. This also protects graph expansion and fallback/retry paths from leaking excluded sources.
That means a valid filter with no matching sources returns an empty result set; HydraDB does not silently widen it into an unfiltered search.

Filter semantics

metadata_filters are hard constraints, not semantic hints. A filter like { "mood": "happy" } requires an exact stored value; it does not expand to related values like “joyful” or “cheerful”. To search metadata text semantically, declare a VARCHAR field with enable_dense_embedding and include the concept in the main query.

3. Define tenant metadata schema

Most of the time the defaults are right. When they aren’t, here’s where to start:
  • Plan scoping fields before first ingest. Schema is immutable, and undeclared scope keys are silently ignored at query time. If you’ll scope on it more than once, declare it.
  • Pick metadata for hot paths, additional_metadata for cold ones. Database-level scopes are pre-applied in the vector store; document-level scopes force a post-retrieval pass with over-fetch.
  • Use exact-equality only. metadata_filters are equality constraints across all categories: a scalar is an exact match, and a list matches any one of its values. Range, contains, or fuzzy matching belong in the query, query mode, or downstream reranking - not here.
  • Keep keys stable. Renaming a metadata key requires re-ingesting affected sources. Same goes for changing enable_match flags.
  • Ingested metadata is immutable. Once a source is indexed, its metadata and additional_metadata values are locked. To change them, re-ingest the source with the new values (use upsert: true and the same id).
  • Don’t substitute metadata_filters for collection. metadata_filters scopes results inside a partition. For partitioning by user, team, or workspace, use collection.

4. Minimal working example

Two phases: set up metadata (declare the schema, then attach values at ingest), then scope at query.

Step 1 - Create metadata

The schema lives at the database level; values land on each source at ingest time. Both happen before any query.

Step 1a: Declare the schema at database creation

Schema field options

Limits and guardrails:
  • Up to 32 custom tenant metadata fields.
  • 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. enable_match does not count against this limit - only the embedding flags do. Exceeding it fails database creation with 400 before anything is provisioned.
  • Field names are unique case-insensitively.
  • Dense/sparse embedding flags are only valid on VARCHAR fields.
  • Runtime metadata values must match the declared type when the tenant has a schema.
  • Unknown metadata keys are rejected on ingest/edit when a non-empty tenant schema exists.
  • Each metadata layer has a byte budget per request - see Size limits.

Size limits

Every request that attaches metadata is checked against two caps, on ingest and on metadata edit alike: Older spellings are still accepted, but not uniformly - which one works depends on the endpoint: Where an alias is accepted it is held to exactly the same cap as the canonical field. Use the canonical names above and this never comes up. 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 worth planning around:
  • Keys and punctuation count. Quotes, colons, commas and braces are all part of the payload you are billed for.
  • 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.
Document metadata: 1,015 bytes, just inside the 1 KiB cap
Exceeding either cap fails the whole request with 400 before anything is ingested. The message names the offending field and reports both numbers, so you can see exactly how far over you are:
On PATCH /context/{id}/metadata the same message is prefixed with invalid metadata edit:.
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 actually filter on.

Filter size limits

The caps above bound the metadata you store. metadata_filters on /query has its own, separate pair - these bound what you send at query time and are unrelated to how much metadata a source carries: Measured the same way - compact JSON, UTF-8 bytes, keys and punctuation counted - and the object total includes the nested additional_metadata dict. Both 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. Over 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.

Add schema fields later

You can add database metadata fields after database creation with PATCH /databases/{database}/metadata-schema. This is additive only:
  • add new fields: yes
  • delete fields: no
  • change type/flags of existing fields: no
Adding fields updates the stored database schema and MongoDB filter indexes. Existing Milvus collections are not altered/backfilled for newly added dense/sparse metadata lanes yet. If you need a new metadata field to participate in semantic/BM25 metadata search for existing data, create a new database/schema and re-ingest, or confirm the current platform migration path with support.

5. Attach metadata at ingest

For knowledge ingestion, send metadata and additional_metadata on each document_metadata[] item or app_knowledge[] item.
For type=memory, the memories multipart field is already JSON-stringified, and each memory item’s metadata is currently validated as a JSON-encoded string. Keep additional_metadata as an object. For knowledge ingestion (document_metadata and app_knowledge), metadata is an object.

6. Query with metadata filters

Mix database-level (top-level) and document-level (nested) scopes in the same metadata_filters object:
Use the legacy alias only when maintaining older clients:
If both aliases are present and both are objects, additional_metadata wins on conflicts.

7. Update metadata without re-ingesting

Use PATCH /context/{id}/metadata when you know the source ID and need to update metadata in place.
Behavior:
  • The source must already exist.
  • collection is required.
  • At least one of tenant_metadata, additional_metadata, or acl is required.
  • The update is a merge/upsert: sent keys are inserted or overwritten; omitted keys are preserved.
  • document_metadata is not accepted on this endpoint; use additional_metadata.
  • The same endpoint accepts acl to change who may retrieve the source. Unlike metadata, acl replaces rather than merges, and an acl-only body is a valid edit. See Access Control.
  • enable_match-only tenant metadata updates are MongoDB-only and take effect for filters/listing.
  • If an edited tenant metadata field has enable_dense_embedding or enable_sparse_embedding, HydraDB synchronously syncs the relevant vector store lane and reports vector_sync_required / vector_synced in the response (the milvus_sync_required / milvus_synced aliases are still emitted, deprecated).
For full document/content replacement, re-ingest with upsert: true and the same source id. Upsert replaces the source payload and metadata supplied by ingestion.

8. Listing with metadata filters

Use POST /context/list when you want to browse or page sources rather than run semantic retrieval:
/context/list also accepts legacy aliases tenant_metadata for metadata and document_metadata for additional_metadata.

9. Common mistakes


10. Advanced patterns

Stacked scopes with collection partitioning. Use collection for the partition (per-user, per-workspace), and use metadata_filters to scope inside that partition. They’re complementary, not interchangeable. See Multi-Tenant. Published vs draft. Add a status field with enable_match: true to your schema; tag every source with metadata.status = "draft" | "published"; pass metadata_filters: { status: "published" } on user-facing queries. Keeps work-in-progress out of customer answers automatically. Multi-language corpora. Add a language field with enable_match: true; route each query to the right language by passing metadata_filters: { language: detect_language(query) }. Schema-as-product. Treat database_metadata_schema as part of your data contract - review it like a database migration. The cost of getting it wrong (immutability + re-ingest) is real; the cost of getting it right is one extra meeting.