# Operate hybrid search and grounded answers

> Index published documentation in Qdrant, evaluate shadow traffic, cut over safely, and operate optional grounded AI answers.

Nibleaf v0.2 can retrieve published English, Arabic, and mixed-language content
with a tenant-filtered BM25 and dense-vector hybrid index. Results mode remains
available without an answer model. Grounded answers are a separate, explicit
instance and site opt-in.

## Understand the boundary

`@nibleaf/search` owns language-aware chunking, normalization, sparse vectors,
fusion result shaping, citation validation, prompt hardening, and the versioned
analytics event contract. `@nibleaf/qdrant` owns only the versioned collection,
payload indexes, alias, and tenant-checked operations through Qdrant&#39;s official
JavaScript SDK. The server derives every project, deployment, version, language,
visibility, and reader page-grant filter; the worker indexes immutable published
snapshots. Clients cannot submit scope. The published reader calls TanStack Start
server functions through TanStack Query hooks; only the server function reaches
the Hono RPC endpoint, preserving its request context and authorization checks.

The collection name is
`<QDRANT_COLLECTION_ALIAS>_<QDRANT_COLLECTION_VERSION>_<dimensions>`. The alias
is stable. Chunk IDs are deterministic from tenant, deployment, version, page,
ordinal, and content hash, so retries are idempotent. Qdrant&#39;s sparse `bm25`
vector uses an IDF corpus restricted by the same tenant and authorization filter
as the query.

## Configure providers

Start from `.env.production.example`. Keep `SEARCH_RUNTIME=shadow`. Set a
Qdrant API key when Qdrant is reachable outside a private container network.
Set `OPENROUTER_API_KEY` and pin `SEARCH_EMBEDDING_MODEL`. Embeddings use
OpenRouter&#39;s official TypeScript SDK and default to
`openai/text-embedding-3-small`. The configured dimension must match
`QDRANT_VECTOR_SIZE`; changing the embedding model, dimension, or embedding-input
format requires a new collection version.

For the portable production stack, add the v0.2 search overlay to every Compose
operation (including pull, up, config checks, and backup automation):

```bash
docker compose -f docker-compose.prod.yml -f docker-compose.search.yml up -d
```

The overlay is separate so the checksummed v0.1.2 self-host release artifacts
remain immutable. Coolify and the development stack include Qdrant directly.

To offer answers, set `SEARCH_ANSWER_ENABLED=true` and pin
`SEARCH_ANSWER_MODEL`. Answer generation uses the same OpenRouter credential
through OpenRouter&#39;s official TypeScript SDK and native structured output. Then an
administrator must enable **Grounded AI answers** for each intended site.
Quotas are atomic per project and day in Redis. Requests also have a separate
per-IP minute limit, provider timeout, and request cancellation. If Redis is
unavailable, answer quota checks fail closed.

Answer generation is intentionally non-streaming in this release. Nibleaf first
validates the complete structured response, rejects unknown citations and
unsupported claims, and only then returns it. This avoids streaming unvalidated
model text. Search results remain immediate and non-generative.

## Index and evaluate

Publishing enqueues an idempotent deployment index job. A retry diffs chunk IDs
and payloads, skips unchanged chunks, updates metadata without embedding, and
deletes only stale points inside the project and deployment filter. A new
deployment reuses dense vectors from the nearest READY predecessor when the
complete embedding-input hash matches, so unchanged documentation is not fully
re-embedded. Only new or changed chunks call the embedding provider. Access-mode
changes reindex the latest deployment; a visibility mismatch returns no hybrid
result until convergence. To rebuild existing data, enqueue `reindex-project`
through the search queue. Writes always target the configured versioned physical
collection rather than the active read alias, so a new collection can be fully
populated before cutover without clearing the serving collection.

Run the repeatable offline fixtures and provider benchmark:

```bash
pnpm --filter @nibleaf/search test
pnpm --filter @nibleaf/search eval:rag
```

The benchmark reads `packages/search/eval/rag-answer-fixtures.json`, resolves
the configured model IDs against OpenRouter&#39;s live catalog, and records Arabic,
English, mixed-language, citation, groundedness/no-answer, latency, token, and
provider-reported cost evidence under `output/search-eval/`. It does not invent
missing cost or usage. Keep output files private if they contain sensitive docs.

Compare shadow retrieval by tenant, language, authorized reader scope, zero-result
rate, ranking, and latency. The current server returns legacy results in shadow
mode; hybrid failures cannot affect readers. Search telemetry is emitted through
`SearchAnalyticsEventV1`; send that contract to the separate analytics pipeline
rather than creating a second search analytics store.

## Cut over and roll back

1. Keep the current alias active and bump `QDRANT_COLLECTION_VERSION` for a
   schema, chunker, model, or vector-dimension migration.
2. Populate the new physical collection and verify exact counts per project,
   deployment, version, language, visibility, and authorized page set.
3. Exercise public, workspace, and restricted-reader negative cases in both
   languages. Confirm private chunks, facets, counts, citations, caches, and
   analytics context never cross scope.
4. Switch the alias explicitly, confirming both collection names. For example,
   after configuring `QDRANT_COLLECTION_VERSION=v2` and a 1536-dimension index:

   ```bash
   QDRANT_PREVIOUS_COLLECTION=nibleaf_search_active_v1_1536 \
   QDRANT_SWITCH_TO=nibleaf_search_active_v2_1536 \
   pnpm --filter @nibleaf/qdrant alias:switch
   ```

   Then set `SEARCH_RUNTIME=hybrid` and watch error, no-result, latency, and
   answer quality signals.
5. Retain the previous physical collection until the observation window closes.

For an immediate retrieval rollback, set `SEARCH_RUNTIME=legacy`; restart the
server only if your environment does not reload configuration. To roll back a
collection migration, point the alias at the retained collection and restore
the matching embedding model and dimension. Disabling `SEARCH_ANSWER_ENABLED`
removes answer capability without affecting results mode.

## Project settings and privacy-safe diagnostics

Project administrators can set **1–50 results** per query (default **12**) and
independently show or hide reader language/version filters (both default on).
Grounded answers default off and the shortcut defaults to `⌘K`. An empty
authored placeholder falls back to the active language&#39;s Paraglide site copy;
language overrides first fall back to the project value, then that localized
copy.

Settings update only `Project.config.search` through an organization-scoped
compare-and-swap transaction, preserving concurrent sibling config writes. A
successful write invalidates local live config immediately; other API replicas
converge within the documented 15-second TTL. Missing keys restore defaults.

Authorized members can inspect only the current READY revision. Diagnostics use
exact tenant/deployment-scoped Qdrant counts/facets plus one bounded metadata
page, never a full corpus scan. Results include logical schema/model/revision
identifiers, aggregate counts, distributions, stable run/error codes, and
bounded page ID/ordinal/language/version/status metadata. They omit content,
titles, paths, hashes, vectors, credentials, physical collection/alias names,
and provider payloads. Provider absence means unavailable with unknown counts,
never zero. Pagination cursors are versioned, HMAC-signed, and tenant/revision
bound.

`SearchIndexRun` is additive, privacy-minimized state. A partial unique index
allows one PENDING/RUNNING row per project. Workers use renewable claims longer
than any configured provider timeout, recover the same durable run after a
crash, replay READY/DISABLED terminal state idempotently, and reclaim only an
expired abandoned claim. Unchanged chunks remain unembedded.

Application rollback may safely leave the table. For an operator schema
rollback, stop API/worker processes and execute
`packages/database/prisma/migrations/20260823180000_search_configuration_diagnostics/rollback.sql`.
It deletes diagnostics history only; Qdrant data is unchanged.

## Remove data

Project deletion enqueues a project-filtered Qdrant delete across every retained
versioned collection under the configured alias prefix. Deployment cleanup uses
the same sweep through `delete-deployment`. Confirm an exact project-scoped
count of zero after the job, then apply your normal Qdrant backup-retention
policy. Never delete a physical collection while its alias is serving traffic.
