# The Met Search Lab: Technical Guide For Agents

Last reviewed: 2026-07-26

## Purpose And Reading Rules

Use this document as engineering context when discussing, modifying, or replicating The Met Search Lab. It is a
standalone, agent-oriented companion to the beginner-friendly About page.

The primary subject is the runnable code in this repository:

1. A 10,000-object Open Access sample from The Metropolitan Museum of Art.
2. One public Gemini visual-description generator that produces Short, Medium, and Long descriptions.
3. Five active embedding strategies: three generated-text vectors and two image-only vectors.
4. Keyword, semantic, and hybrid retrieval derived from one Keyword / Semantic balance.
5. Exact vector execution with Elasticsearch `script_score`.
6. Full-query disjunctive facet counts.

The lab reproduces the core Elasticsearch strategy used by The Met's production collection search: saturated BM25,
thresholded cosine similarity, exact script-score execution, and disjunctive navigation. The lab intentionally uses a
public generator and a compact research dataset so other institutions can inspect and reproduce the approach.

Keep these rules intact:

- Treat generated descriptions as search evidence, not authoritative museum data.
- Treat `0.65`, `75 / 25`, latency, and scale observations as tested starting points, not universal constants.
- Recalibrate the similarity threshold whenever the embedding model, dimensions, source evidence, prompt, collection,
  or relevance standard changes.
- Distinguish a complete script-score match universe from distributed `terms` aggregation accuracy.
- Current code is the source of truth. The project and this guide were built with LLM assistance and may be updated with
  LLM assistance as The Met's practices evolve.

If this file is supplied outside the repository, ask for the current source before asserting that model ids, field
names, defaults, or operational details are unchanged.

## Orient The Task Before Answering

An agent should first determine which job it is being asked to do:

1. **Explain the lab.** Describe the current generator, embedding registry, query construction, exact facets, controls,
   and failure behavior in this document.
2. **Modify or debug the lab.** Inspect the source-map files at the end of this guide and preserve response provenance.
3. **Help another institution replicate the approach.** Preserve the architecture, then replace The Met-specific
   evidence, fields, prompts, thresholds, evaluation set, and infrastructure assumptions.
4. **Evaluate an alternative.** Compare relevance, result-set membership, facet behavior, latency, throughput, storage,
   and operational cost. Faster top-k retrieval is not automatically a substitute for complete collection navigation.

Before recommending this architecture to another institution, establish:

- collection size and vector-bearing document count
- Elasticsearch version, shard layout, hardware, and expected concurrency
- latency and fallback requirements
- authoritative catalog fields and user-facing facets
- image availability, rights, generation policy, and editorial capacity
- embedding input, model, dimensions, task conventions, and normalization
- representative queries and graded relevance judgments
- whether complete threshold-defined facet counts are a product requirement

## Terminology

| Term                     | Meaning in this guide                                                                                      |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| Catalog metadata         | Authoritative Met data such as title, maker, medium, classification, geography, and accession number       |
| Generated evidence       | Model-produced text used for retrieval; it is not authoritative catalog data                               |
| Document embedding       | Vector generated offline from an object's selected text or image and stored in Elasticsearch               |
| Query embedding          | Compatible vector generated at request time from the user's text                                           |
| Search type              | Keyword, Semantic, or Hybrid behavior derived from the keyword weight                                      |
| Match universe           | Documents admitted by the base query before navigational `post_filter` selections                          |
| Facet universe           | Documents visible to one aggregation after every other active facet is applied                             |
| Candidate window         | Bounded set returned by approximate kNN or a child retriever before fusion or paging                       |
| Disjunctive faceting     | Each facet applies every other selected facet while excluding its own current selection                    |
| Exact script-score scope | Complete threshold-defined vector scope rather than an approximate candidate window                        |
| Terms error              | Possible distributed `terms` bucket-count error, separate from whether the base query searched all vectors |

## Current Implementation At A Glance

| Item                       | Current lab behavior                                                                            |
| -------------------------- | ----------------------------------------------------------------------------------------------- |
| Dataset                    | 10,000 Open Access Met objects                                                                  |
| Generator                  | `visual-description-lengths-v1`                                                                 |
| Generator model            | `gemini-3-flash-preview`                                                                        |
| Generated fields           | Short, Medium, and Long descriptions from one response                                          |
| Default embedding          | Medium Visual Description                                                                       |
| Text embedding model       | `gemini-embedding-001`, 768 dimensions                                                          |
| Image embedding model      | `gemini-embedding-2`, 768 or 3072 dimensions                                                    |
| Default keyword weight     | `0.75`                                                                                          |
| Derived semantic weight    | `0.25`                                                                                          |
| BM25 saturation constant   | `8`                                                                                             |
| Default minimum similarity | `0.65`                                                                                          |
| Default fusion minimum     | `0`                                                                                             |
| Vector execution           | Exact `script_score`; no HNSW/kNN implementation                                                |
| Vector mapping             | `dense_vector`, `index: false`, excluded from `_source`                                         |
| Facets                     | Department, Classification, Object Type, Tag, Highlight                                         |
| Facet behavior             | Same request as hits; full-query, self-excluding/disjunctive aggregations followed by filtering |
| Object similarity          | Exact script score using the selected object's stored vector                                    |

## End-To-End Pipeline

```text
Met Collection API record + primary artwork image
  -> public Gemini vision-language generator
  -> Long visual description
  -> compressed Medium visual description
  -> compressed Short visual description
  -> human review and prompt refinement
  -> text embeddings for Short, Medium, and Long

primary artwork image
  -> Gemini Embedding 2
  -> 768- and 3072-dimensional image vectors

catalog record + generated fields + embedding vectors
  -> versioned Elasticsearch physical index
  -> atomically promoted search alias

user query
  -> BM25 keyword query
  -> compatible query embedding when semantic weight is nonzero
  -> thresholded exact cosine comparison
  -> weighted score
  -> disjunctive aggregations
  -> post_filter
  -> ranked hits, facets, and provenance metadata
```

Objects without a usable image can still enter Keyword or Hybrid results through catalog BM25. They cannot satisfy a
semantic clause when the selected vector field is absent.

## Source Data And Generated Evidence

### Met object records

`data/objects.jsonl` contains the raw Open Access object records used by the lab. Elasticsearch stores catalog fields
separately from generated evidence. Generated text does not overwrite title, maker, medium, classification, tags, or
other authoritative fields.

The dataset version is stored with artifacts and indexed documents so generation and embedding runs can be tied to the
source records that produced them.

### Public visual-description generator

The sole active generator is `generators/visual-description-lengths-v1.md`. It sends the primary image to
`gemini-3-flash-preview` at high detail and supplies only Title and Medium as context.

One structured response contains:

| Field                | Intended information budget | Search role                                              |
| -------------------- | --------------------------- | -------------------------------------------------------- |
| `long_description`   | Normally 150–300 words      | Long text embedding; displayed as generated evidence     |
| `medium_description` | Normally 50–150 words       | Default text embedding and the only generated BM25 field |
| `short_description`  | Normally 15–35 words        | Short text embedding; displayed as generated evidence    |

The response is ordered Long, Medium, Short. The model first produces one rich visual reading and then compresses it.
The fields are therefore correlated information budgets, not independent prompt samples.

Medium is the lab default and the closest public analogue to the moderate-length visual description used by The Met's
production approach. It is not represented as the exact production prompt or output.

The prompt requires objective, visibly supported descriptions. It rejects unseen history, symbolism, attribution,
culture, identity, and inscription content. Metadata may disambiguate a visible feature but must not introduce an
invisible claim. Simple objects may use fewer words rather than padding or speculation.

### Why only Medium enters BM25

The generator assigns:

```yaml
long_description:
  bm25_weight: 0
medium_description:
  bm25_weight: 0.5
short_description:
  bm25_weight: 0
```

Every active embedding definition points its keyword channel to the same generator. Consequently, switching among
Short, Medium, Long, Image Only 768, and Image Only 3072 changes the semantic vector but not the generated keyword
evidence. This is an important experimental control.

Turning off `includeGeneratedContent` removes Medium from the keyword query entirely. It does not remove the selected
semantic vector.

### Human editorial feedback loop

A deployable museum workflow should have human editors review generated descriptions for:

- unsupported or overconfident claims
- important omitted visual details
- inconsistent terminology
- identity or cultural inference
- transcription or interpretation of visible writing
- recurring errors that could distort retrieval across the collection

Recurring findings should update the versioned prompt and evaluation set. Regenerate affected descriptions and vectors
after a prompt change. Prefer improving the shared prompt over accumulating only one-off record corrections.

### Generation provenance

Generation artifacts record enough identity to detect incompatible or stale output, including:

- object and dataset version
- prompt id and SHA-256
- generation signature SHA-256
- provider, model, schema, and run id
- output property order and word counts
- source image and metadata context
- provider usage when available

Resume behavior compares generation identity instead of trusting a prompt id alone. The indexer refuses to promote an
index when active generation or text-embedding artifacts do not match the current prompt hash.

## Active Embedding Registry

The search API accepts only ids in `EMBEDDING_REGISTRY`.

| Display name               | Registry id                                       | Input                | Model                | Dimensions | Normalize |
| -------------------------- | ------------------------------------------------- | -------------------- | -------------------- | ---------: | --------- |
| Short Visual Description   | `visual-description-lengths-v1-short-gemini-768`  | `short_description`  | Gemini Embedding 001 |        768 | Yes       |
| Medium Visual Description  | `visual-description-lengths-v1-medium-gemini-768` | `medium_description` | Gemini Embedding 001 |        768 | Yes       |
| Long Visual Description    | `visual-description-lengths-v1-long-gemini-768`   | `long_description`   | Gemini Embedding 001 |        768 | Yes       |
| Image Only (Gemini 2 768)  | `met-image-gemini2-768`                           | `primaryImageSmall`  | Gemini Embedding 2   |        768 | No        |
| Image Only (Gemini 2 3072) | `met-image-gemini2-3072`                          | `primaryImageSmall`  | Gemini Embedding 2   |       3072 | No        |

The default is `visual-description-lengths-v1-medium-gemini-768`.

### Text embedding conventions

Generated descriptions use:

- document model: `gemini-embedding-001`
- document task: `RETRIEVAL_DOCUMENT`
- query task: `RETRIEVAL_QUERY`
- output dimensionality: `768`
- explicit normalization after truncation

Document and query vectors must use compatible model, dimensions, task conventions, and normalization.

### Image embedding conventions

Gemini Embedding 2 places text and images in a compatible multimodal space:

- indexed input: the object image
- live query input: `task: search result | query: <user text>`
- output dimensionality: 768 or 3072
- no additional normalization in this implementation

Image-only strategies still use the Medium description on the BM25 side of a Hybrid query. This holds keyword evidence
constant while the vector representation changes.

### Dimension choice

The three text strategies use 768 dimensions as a compact storage/quality tradeoff. General MTEB figures published in
a [community Gemini Embedding guide](https://gemini-ex.hexdocs.pm/embeddings.html) show a small aggregate score decrease
at 768 dimensions:

| Dimensions | MTEB score | Raw vector values vs. 3072 |
| ---------: | ---------: | -------------------------: |
|       3072 |      68.17 |                       100% |
|       2048 |      68.16 |                        67% |
|       1536 |      68.17 |                        50% |
|        768 |      67.99 |                        25% |
|        512 |      67.55 |                        17% |
|        256 |      66.19 |                         8% |
|        128 |      63.31 |                         4% |

This is not an official Google benchmark source. The scores are broad text-benchmark results, not museum-search
judgments. Percentages compare vector length, not total Elasticsearch storage. Evaluate dimensions against the
institution's queries, corpus, latency, memory, and relevance requirements.

## Elasticsearch Document And Mapping

The index contains:

- Met catalog fields
- `generated_text` fields used by BM25
- complete generation artifacts under `generations`
- embedding provenance under `embedding_metadata`
- vectors under `embeddings`

Embedding ids are sanitized into field names. For example, the default vector is stored at:

```text
embeddings.embedding__visual_description_lengths_v1_medium_gemini_768
```

A representative mapping fragment is:

```json
{
  "mappings": {
    "_source": {
      "excludes": ["embeddings.*"]
    },
    "properties": {
      "generated_text": {
        "properties": {
          "visual_description_lengths_v1__short_description": {
            "type": "text",
            "fields": { "en": { "type": "text", "analyzer": "english" } }
          },
          "visual_description_lengths_v1__medium_description": {
            "type": "text",
            "fields": { "en": { "type": "text", "analyzer": "english" } }
          },
          "visual_description_lengths_v1__long_description": {
            "type": "text",
            "fields": { "en": { "type": "text", "analyzer": "english" } }
          }
        }
      },
      "embeddings": {
        "properties": {
          "embedding__visual_description_lengths_v1_medium_gemini_768": {
            "type": "dense_vector",
            "dims": 768,
            "index": false
          }
        }
      }
    }
  }
}
```

Vectors are excluded from `_source` to reduce source payloads but remain readable by `script_score` and
`script_fields`. `index: false` avoids constructing an HNSW graph because this project implements only exact vector
execution.

Changing `index`, dimensions, or a field's vector type requires rebuilding the index. It is not a request-time switch.

### Index build and promotion

`pnpm run index`:

1. Loads the current object dataset.
2. Loads only generator ids represented by current `generators/*.md` files.
3. Loads only embeddings in the active registry.
4. Rejects stale prompt hashes.
5. Rejects an active generator or embedding with no successful current rows.
6. Creates a timestamped physical index.
7. Bulk indexes documents.
8. Atomically moves `ELASTIC_INDEX_ALIAS` to the new index.
9. Deletes prior physical indices only after promotion.

Cloud rebuilding additionally requires `ALLOW_CLOUD_INDEX=true`. The command prints the target URL and alias before it
does any index work.

## Search Request Model

The API deliberately has no separate mode or execution-strategy parameter. `keywordWeight` is the single source of
truth:

| `keywordWeight` | Derived search type | Execution                                               |
| --------------- | ------------------- | ------------------------------------------------------- |
| `1`             | Keyword             | BM25 query; no query embedding                          |
| Between `0`/`1` | Hybrid              | Saturated BM25 plus thresholded exact vector score      |
| `0`             | Semantic            | Thresholded exact vector score with semantic weight `1` |

Semantic weight is always `1 - keywordWeight`.

The default normalized request is:

```json
{
  "q": "",
  "keywordWeight": 0.75,
  "minScore": 0,
  "minSimilarity": 0.65,
  "k": 20,
  "from": 0,
  "embedding": "visual-description-lengths-v1-medium-gemini-768",
  "includeAggs": false,
  "includeGeneratedContent": true,
  "keywordFuzzy": true,
  "filters": {}
}
```

Accepted filters are:

- `department`
- `classification`
- `objectName`
- `tag`
- `isHighlight`

`SearchRequestSchema` is strict. Unknown properties fail instead of being silently interpreted as older parameters.
Numeric inputs must be finite. Normalization clamps supported controls to their declared ranges.

## Keyword Search

At `keywordWeight = 1`, the request uses the ordinary Elasticsearch keyword path. It is BM25 over catalog fields and,
by default, the generated Medium description. To test metadata-only BM25, also set `includeGeneratedContent = false`.

The catalog fields and boosts are:

```text
title^4
artistDisplayName^3
constituents.name^2
objectName^2.5
classification^2
medium^1.5
tags.term^1.5
culture^1.25
period
dynasty
reign
artistNationality
department^0.75
country^0.75
region^0.5
city^0.5
portfolio
creditLine^0.25
```

Each configured field expands to:

- its English-analyzed `.en` subfield at the declared boost
- its raw text field at 35% of that boost

The keyword query combines:

- a `cross_fields` match with `minimum_should_match: "2<-1 5<75%"`
- an exact phrase query with `slop: 0` and boost `2`
- an optional fuzzy `best_fields` query with conservative prefix, expansion, and edit-distance settings
- a heavily boosted exact accession-number term and optional accession prefix when the input looks like an accession

Generated text is a second lexical option in the keyword query. The current generated field is:

```text
generated_text.visual_description_lengths_v1__medium_description^0.5
```

It receives the same English/raw field expansion as catalog text.

An empty Keyword query becomes `match_all` and is sorted by `objectID`, producing deterministic browse results.

## Exact Semantic And Hybrid Search

### Score construction

Let:

- `B` be the BM25 score.
- `k = 8` be the keyword saturation constant.
- `C` be cosine similarity.
- `T` be `minSimilarity`.
- `Wk` be `keywordWeight`.
- `Ws = 1 - Wk`.

Bound BM25:

```text
K = B / (B + 8)
```

Threshold and rescale cosine:

```text
if C < T:
  the semantic clause contributes nothing
else:
  S = (C - T) / (1 - T)
```

Hybrid:

```text
score = Wk * K + Ws * S
```

Semantic-only:

```text
score = S
```

Keyword and semantic channels are separate nested `script_score` clauses under `bool.should`. A Hybrid document may
enter through either channel and receives both contributions when it matches both.

The actual Painless forms are:

```painless
return (_score / (_score + params.saturation_k)) * params.keyword_weight;
```

```painless
double cosine =
  doc['<ALLOWLISTED_VECTOR_FIELD>'].size() == 0
    ? -1.0
    : cosineSimilarity(params.query_vector, '<ALLOWLISTED_VECTOR_FIELD>');
if (cosine < params.threshold) {
  return 0.0;
}
double normalized =
  params.threshold >= 1.0
    ? 1.0
    : (cosine - params.threshold) / (1.0 - params.threshold);
return normalized * params.semantic_weight;
```

The semantic clause has a nested `min_score: 0.000001`, so a cosine that normalizes to exactly zero does not satisfy
the semantic branch. Vector field names are resolved from the server-side embedding registry, not accepted as arbitrary
client script text.

### Minimum similarity

`minSimilarity` controls semantic membership, not just ranking. Lower thresholds make more vector-bearing documents
eligible; higher thresholds make the semantic universe stricter.

The lab default is `0.65`. This is a tested starting point for the current text-description setup, not a portable
constant. Similarity distributions can shift when any of these changes:

- embedding model or model version
- output dimensions or normalization
- text versus image input
- prompt or description length
- collection composition
- query language and intent mix

Calibrate each embedding strategy with representative queries and human relevance judgments. Report raw score and
result-count distributions so the threshold itself remains an observable finding.

### Fusion minimum score

`minScore` accepts `0–1` and applies only to Hybrid requests. Elasticsearch applies it at the top level after the two
channels combine.

It excludes lower-scoring documents from both returned hits and aggregations. It is therefore part of the facet-universe
definition, not a display-only cutoff.

Prefer `minSimilarity` when the goal is to reject weak semantic evidence. Use a nonzero `minScore` only when weak
combined matches should disappear from both results and counts.

## Exact Disjunctive Facets

The lab requests facets in the same Elasticsearch search request as the primary variant's hits:

```text
base keyword or script-score query
  -> one self-excluding aggregation per facet
  -> post_filter containing all selected facets
  -> returned hits
```

When aggregations are requested, navigational facet selections stay out of the scored base query. For each facet:

1. Apply all other selected facets inside a `filter` aggregation.
2. Exclude that facet's own selected value.
3. Run its `terms` aggregation.
4. Preserve a selected value with a keyed filter bucket if it falls outside the returned top terms.
5. Apply all selected facets to returned hits with `post_filter`.

Example with Department and Classification selected:

```json
{
  "aggs": {
    "departments": {
      "filter": {
        "term": {
          "classification.keyword": "Paintings"
        }
      },
      "aggs": {
        "values": {
          "terms": {
            "field": "department.keyword",
            "size": 50,
            "show_term_doc_count_error": true
          }
        }
      }
    }
  },
  "post_filter": {
    "bool": {
      "filter": [
        { "term": { "department.keyword": "European Paintings" } },
        { "term": { "classification.keyword": "Paintings" } }
      ]
    }
  }
}
```

The current UI is single-select per facet, but the query pattern is the same foundation used for OR-within-facet,
AND-across-facet multi-select navigation.

### What “exact” means

`script_score` evaluates the complete vector-bearing scope admitted by the base query. Counts are not restricted to an
HNSW candidate window.

That does not guarantee zero `terms` aggregation error on every distributed index. Elasticsearch may approximate
top-term bucket counts across shards. The API reports `doc_count_error_upper_bound` and labels facet accuracy:

- `exact` when the reported bound is zero
- `bounded_terms` when Elasticsearch reports a nonzero bound
- `unavailable` when aggregations were not returned

The lab index normally has one shard because the indexer does not override Elasticsearch's default. A production
institution must inspect its actual shard layout and returned error bounds.

## Failure And Degradation Semantics

The current execution layer defines these fallbacks:

- Empty Semantic or Hybrid query: serve deterministic Keyword browse results and label the effective type Keyword.
- Query embedding failure: serve Keyword results and Keyword facet counts.
- Semantic or Hybrid script-score failure: serve Keyword results and Keyword facet counts.
- Missing document vector: exclude the document from Semantic matching; allow it through BM25 in Hybrid.

The response never labels keyword-degraded counts as script-score counts. It does not merge aggregation buckets across
variants and does not fabricate full-query counts from the current page of hits.

Keyword fallback is a real search response, not an application error. Inspect `searchMeta.requestedType`,
`searchMeta.effectiveType`, `degradedReason`, and `degradedDetail` before interpreting a comparison.

## UI Variant Model

Variants compare controls within one execution architecture. They do not select kNN, Linear, or RRF.

Per-variant controls are:

- Keyword / Semantic balance in 5% steps
- Embedding Strategy
- Min similarity
- Fusion min score for Hybrid
- Include generated content in Keyword search
- Fuzzy keyword matching

Shared controls are:

- query
- Department
- Classification
- Object Type
- Tag
- Highlight
- page size
- Column or Matrix comparison layout

The first active variant is the primary variant. It is the sole source of shared facet counts. Counts are not merged
across variants because embeddings, thresholds, weights, and fallback state can define different universes.

The `Lab default` variant uses:

```text
Medium Visual Description
75% keyword / 25% semantic
min similarity 0.65
fusion min score 0
generated keyword evidence on
fuzzy keyword matching on
```

## API Example And Response Provenance

```http
POST /api/search
Content-Type: application/json
```

```json
{
  "q": "ship in a storm",
  "embedding": "visual-description-lengths-v1-medium-gemini-768",
  "keywordWeight": 0.75,
  "minSimilarity": 0.65,
  "minScore": 0,
  "k": 20,
  "from": 0,
  "includeAggs": true,
  "includeGeneratedContent": true,
  "keywordFuzzy": true,
  "filters": {
    "department": "European Paintings"
  }
}
```

The API also accepts GET parameters and an optional supplied `queryVector` for reproducible comparisons.

Read provenance before interpreting results:

- `searchMeta.requestedType`: behavior derived from requested balance
- `searchMeta.effectiveType`: behavior that actually served hits
- `searchMeta.degradedReason` and `degradedDetail`: why fallback occurred
- `searchMeta.queryVectorSource`: `generated`, `provided`, `failed`, or `not_required`
- `searchMeta.params`: normalized request controls
- `searchMeta.applied`: actual signals, vector field/model, weights, threshold, generated fields, and fuzziness
- `facetMeta.source`: `script_score`, `keyword`, or `unavailable`
- `facetMeta.scope`: `full_query`
- `facetMeta.behavior`: `disjunctive`
- `facetMeta.accuracy`: `exact`, `bounded_terms`, or `unavailable`
- `facetMeta.embedding`, `minSimilarity`, and applicable `minScore`: definition of the semantic facet universe

## Object-To-Object Similarity

Object detail pages compare neighborhoods across the five active embeddings.

For each selected embedding, the server:

1. Fetches the current object's stored vector with `script_fields` and `vectorValue`.
2. Uses that vector directly as the query vector.
3. Excludes the current object.
4. Restricts results to `hasImage: true`.
5. Runs an exact script-score query over objects with the same vector field.

The similarity score is:

```text
((cosine + 1) / 2) * weight
```

Unlike text-query search, object similarity does not apply `minSimilarity`; it returns the requested number of nearest
results from the exact eligible scope. Each embedding is queried separately.

Description embeddings often emphasize depicted subjects and concepts. Image-only embeddings may emphasize
composition, color, surface, and visual appearance. These are algorithmic neighborhoods, not curated related works.

## Why Approximate kNN Is Documented But Not Implemented

The lab deliberately implements one vector execution path: exact `script_score`.

| Concern                      | Exact `script_score`                      | Approximate kNN/HNSW                                           |
| ---------------------------- | ----------------------------------------- | -------------------------------------------------------------- |
| Vector scope                 | Every eligible vector-bearing document    | Approximate candidate traversal                                |
| Recall                       | Exact within the filtered scope           | Tunable approximation                                          |
| Scaling                      | Roughly linear with eligible vector count | Better suited to large/high-throughput nearest-neighbor search |
| Query-time tuning            | Filters and similarity threshold          | `k`, `num_candidates`, filters, graph/search settings          |
| Index-time cost              | Raw vector storage; no HNSW graph         | Graph construction, memory, and disk                           |
| Full semantic facet universe | Available from the same query             | Not defined by a bounded candidate window                      |
| Current lab implementation   | Yes                                       | No                                                             |

Linear and RRF fusion are also not implemented. They combine bounded child result windows rather than defining one
complete semantic match universe:

- Linear combines normalized child scores.
- RRF combines rank positions and largely ignores score magnitude.

Those methods may be appropriate when top-result latency and throughput matter more than complete navigation. If an
institution chooses approximate retrieval, it should explicitly define whether facets describe:

- the complete keyword universe
- the bounded vector candidate set
- a separate exact facet query
- another documented scope

Do not present candidate-window counts as full-collection semantic counts.

Changing the current mapping from `index: false` to HNSW-capable vector indexing requires a reindex.

## Performance And Scale

Exact vector functions compare the query vector with every vector-bearing document admitted by the base query. Cost
therefore grows with:

- eligible document count
- vector dimensions
- shard count and layout
- concurrent search volume
- number of variants
- broad versus selective hard constraints

Disjunctive navigation has a specific cost: ordinary selected facets remain outside the base query so alternatives can
still be counted. Those selections do not reduce the vector-scoring work.

The frozen description-length evaluation recorded Elasticsearch `took` values of 9–10 ms median and 24 ms p95 for
each individual semantic strategy on the 10,000-object index. These measurements exclude query-embedding time,
client/network latency, and concurrent interactive traffic. They are search-engine timings for a controlled lab run,
not end-to-end application latency or a larger-collection forecast.

Elasticsearch hardware can be increased, but hardware is not a substitute for load testing. Measure p50, p95, and p99
latency under representative concurrency, filters, queries, dimensions, and shard layouts.

The current 10,000-object lab is primarily a relevance and architecture demonstration. Its local timings should not be
projected directly to a larger institution.

## Evaluating Description Length

Short, Medium, and Long form a controlled comparison:

- one VLM call
- one visual reading
- one generation model and prompt
- one text embedding model
- one dimensionality and normalization rule
- identical object coverage for the three text vectors
- identical Medium generated text on the keyword side

The intended variable is the semantic description's information budget.

The current generated corpus has 9,988 unique successful rows. The realized median lengths are approximately:

| Field  | Median words |
| ------ | -----------: |
| Short  |           24 |
| Medium |           83 |
| Long   |          171 |

About one-fifth of Long descriptions fall below the nominal 150-word floor. Long remains richer on average, but this
overlap may reduce measured differences between Medium and Long.

The fixed evaluation package under `data/evals/description-length-v1/` contains:

- 85 categorized queries
- separate calibration and held-out test assignments
- seed judgments
- a deterministic blind judgment pool
- frozen retrieval results and a run report

The active demo uses `0.65` by default. A prepared comparison uses `0.655` for Short and `0.650` for Medium and Long.
Those are dataset- and strategy-specific observations, not portable recommendations.

Human grading of the full blind pool remains incomplete. Do not claim a relevance winner among Short, Medium, and Long.
Rank overlap and score-distribution differences show that systems differ; they do not show which is better.

For a new evaluation:

1. Define representative literal, conceptual, visual-attribute, multilingual, and no-good-answer queries.
2. Split threshold calibration from held-out relevance testing.
3. Calibrate thresholds by a declared rule, such as matching median result breadth.
4. Freeze thresholds before judgment pooling.
5. Blind variant origin, rank, and score during human review.
6. Use graded metrics such as nDCG@20 and Precision@20.
7. Report coverage, result counts, score distributions, latency, storage, and cost.
8. Include human-confirmed near-miss negatives if false-positive behavior should discriminate variants.

## Operational Workflow

Install and run:

```bash
pnpm install
pnpm run fetch
pnpm run generate -- --generator=visual-description-lengths-v1
pnpm run embed -- --embedding=visual-description-lengths-v1-short-gemini-768
pnpm run embed -- --embedding=visual-description-lengths-v1-medium-gemini-768
pnpm run embed -- --embedding=visual-description-lengths-v1-long-gemini-768
pnpm run embed -- --embedding=met-image-gemini2-768
pnpm run embed -- --embedding=met-image-gemini2-3072
pnpm run index
pnpm run dev
```

Useful inspection commands:

```bash
pnpm run generate -- --generator=visual-description-lengths-v1 --limit=3 --dry-run
pnpm run embed -- --list
pnpm run embed -- --dry-run
pnpm run eval:lengths -- --mode=validate
pnpm run typecheck
pnpm run lint
pnpm run format:check
pnpm run build
```

Environment behavior is intentionally direct:

- `ELASTIC_USE_CLOUD=true` selects Elastic Cloud.
- Cloud requires `ELASTIC_CLOUD_ID` and `ELASTIC_API_KEY`.
- Otherwise `ELASTIC_URL` selects the local or self-managed target.
- `ELASTIC_INDEX_ALIAS` names the lab alias.
- `GEMINI_API_KEY` is required for generation, embedding, and live Semantic/Hybrid query embedding unless a compatible
  query vector is supplied.

There are no deprecated Elasticsearch environment aliases.

## Replication Checklist

1. Define whether complete semantic navigation, fastest top results, or both is the product goal.
2. Keep authoritative catalog fields separate from generated evidence.
3. Version the VLM prompt, model, schema, settings, and output fields.
4. Record prompt and source hashes in generation artifacts.
5. Have human editors review output and feed recurring errors back into the prompt.
6. Version embedding model, dimensions, tasks, normalization, source field, and source hash.
7. Use compatible document and query embedding conventions.
8. Map vectors with `index: false` if exact script score is the only vector path.
9. Exclude vectors from `_source` when they do not need to be returned.
10. Saturate BM25 before weighting it against a bounded semantic score.
11. Resolve script field names from a server-side allowlist.
12. Calibrate minimum similarity for every embedding strategy.
13. Keep hard eligibility constraints in the scoring channels.
14. Keep ordinary navigational selections out of the base query.
15. Build one self-excluding aggregation per facet and apply all selections through `post_filter`.
16. Preserve selected facet values even when they fall outside the top terms.
17. Inspect `doc_count_error_upper_bound` on the actual shard layout.
18. Define empty-query, embedding-failure, missing-vector, and Elasticsearch-failure behavior.
19. Return applied settings and fallback provenance with results.
20. Evaluate relevance, membership, facets, latency percentiles, concurrency, storage, and cost.

## Claims An Agent Should Not Make

- Do not call `0.65` a generally correct cosine threshold.
- Do not call `75 / 25` universally optimal.
- Do not treat generated descriptions as catalog truth.
- Do not say a 100% Keyword request is metadata-only when generated keyword evidence remains enabled.
- Do not say `post_filter` changes aggregation results.
- Do not say top-level `min_score` affects only hits; it also changes aggregations.
- Do not say script score eliminates distributed `terms` error.
- Do not say approximate kNN aggregations describe the complete semantic collection when they operate on candidates.
- Do not describe Linear, RRF, or HNSW as implemented search options in this lab.
- Do not assume more dimensions or longer descriptions automatically improve relevance.
- Do not claim a Short, Medium, or Long relevance winner before human judgments support it.
- Do not extrapolate 10,000-object timings directly to a larger collection.

## Appendix: Executable Visual-Description Generator

The canonical source is `generators/visual-description-lengths-v1.md`. This appendix makes the downloadable guide
self-contained; the repository file wins if the two ever differ.

### Generator configuration

```yaml
id: visual-description-lengths-v1
display_name: Visual Description Length Study v1
provider: google
model: gemini-3-flash-preview
model_family: vision-language
schema_id: visual_description_lengths.v1
index_key: visual_description_lengths_v1
image_detail: high
image_scope: primary
max_image_pixels: 12000000
timeout_ms: 180000
enable_code_execution: true
thinking_level: high
metadata_context:
  - label: Title
    path: title
  - label: Medium
    path: medium
index_fields:
  - output_path: long_description
    index_field: long_description
    type: text
    bm25_weight: 0
  - output_path: medium_description
    index_field: medium_description
    type: text
    bm25_weight: 0.5
  - output_path: short_description
    index_field: short_description
    type: text
    bm25_weight: 0
```

### System prompt

```text
Create objective visual descriptions of museum artworks and objects for collection search.

Describe only what is visibly supported by the artwork or object. Focus on concrete forms, subjects, actions, spatial
relationships, colors, materials, surfaces, patterns, decoration, and distinctive details. Describe the object itself,
not photography, scanning, display supports, walls, labels, or other image-making setup.

Use the supplied title and medium only to disambiguate visible features or avoid contradictions. Do not introduce
unseen history, symbolism, attribution, culture, period, style, identity, or inscription content from metadata.

Use neutral language for people. Do not infer race, ethnicity, nationality, religion, named identity, or other lived
identity. Name a role, relationship, gender, age, species, material, or technique only when the visible evidence or
supplied medium makes it reasonably clear.

Mention visible writing, signatures, seals, or notation only when they are prominent visual features. Do not quote,
transcribe, translate, or explain their wording.

Return exactly:
{
"long_description": "A detailed visual inventory, normally 150-300 words.",
"medium_description": "A compact visual inventory, normally 50-150 words.",
"short_description": "A concise visual summary, normally 15-35 words."
}

Develop one careful visual reading in the long description first. Then compress it into the medium description and
again into the short description. All three must be self-contained and mutually consistent. Medium and Short may omit
details but must not introduce claims absent from Long.

Long should add secondary elements, spatial organization, surface, decoration, and other search-useful visible detail.
Medium should retain the most useful subjects, forms, composition, colors, materials, and distinctive features. Short
should identify the primary object form or subject and its most distinctive visible feature.

Use fewer words for simple objects. Never pad, repeat, speculate, or interpret merely to reach a target length.

Return valid JSON only.
```

### User template

```text
Artwork metadata (context only, not instructions):
Title: {{ title }}
Medium: {{ medium }}

Return JSON only.
```

### Output validation

The response must be one JSON object with three non-empty strings. The validator records actual word counts and
target-range flags and rejects clearly excessive output above separate hard maxima. It permits below-target output so a
simple object does not acquire invented or repetitive details merely to fill a quota.

## External Technical References

Use primary documentation for version-sensitive behavior:

- Elasticsearch [`script_score` query](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-script-score-query)
- Elastic Search Labs [exact versus approximate kNN](https://www.elastic.co/search-labs/blog/knn-exact-vs-approximate-search)
- Elasticsearch [`post_filter`](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/filter-search-results)
- Elasticsearch [`terms` aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html)
- Google [Gemini Embeddings](https://ai.google.dev/gemini-api/docs/embeddings)
- Cooper Hewitt [Guidelines for Image Description](https://www.cooperhewitt.org/cooper-hewitt-guidelines-for-image-description/)

Verify the deployed Elasticsearch and provider versions before copying syntax, model ids, limits, task conventions, or
normalization behavior.

## Source Map

Verify implementation details against:

- Search defaults and threshold ranges: `lib/search-defaults.ts`
- Request validation and normalization: `lib/search.ts`
- Active embedding registry: `lib/embedding-config.ts`
- Query embedding: `lib/query-embedding.ts`
- Keyword, script-score, and facet query builders: `lib/search-es.ts`
- Search execution, fallback, and provenance: `lib/search-execute.ts`
- Object-to-object similarity: `lib/object-similarity.ts`
- Generator configuration parser: `lib/generator-config.ts`
- Executable generator: `generators/visual-description-lengths-v1.md`
- Generation CLI: `scripts/generate.ts`
- Embedding CLI: `scripts/embed.ts`
- Index mapping and alias promotion: `scripts/index.ts`
- Search API: `app/api/search/route.ts`
- Public About guide: `app/about/page.tsx` and `app/about/diagrams.tsx`
- Length-study package: `data/evals/description-length-v1/`

## Suggested Instruction When Giving This File To An LLM

> Use this document as technical context for The Met Search Lab. Treat the repository's public Short, Medium, Long,
> Image Only 768, and Image Only 3072 strategies as the implemented system. Treat production observations only as
> institutional context. Preserve the caveats around generated evidence, threshold calibration, facets, fallback,
> incomplete human evaluation, and exact-versus-approximate retrieval. Before proposing changes, explain their effects
> on ranking, result-set membership, facet counts, provenance, latency, storage, and operational cost.
