# qdrant-es-gateway [![CI](https://github.com/dstockton/qdrant-es-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/dstockton/qdrant-es-gateway/actions/workflows/ci.yml) [![GHCR](https://ghcr-badge.egpl.dev/dstockton/qdrant-es-gateway/latest_tag?trim=major&label=ghcr)](https://github.com/dstockton/qdrant-es-gateway/pkgs/container/qdrant-es-gateway) `qdrant-es-gateway` exposes a deliberately useful subset of the Elasticsearch REST API backed by the Qdrant engine. It targets ordinary application search—catalogues, documentation, jobs, tickets, and content—not Kibana, logging, or complete Elasticsearch replacement. [![Launch video](docs/assets/qdrant-es-gateway-launch.jpg)](docs/assets/qdrant-es-gateway-launch.mp4) See the [launch video](docs/assets/qdrant-es-gateway-launch.mp4) for the endpoint-only migration story and measured 500,000-product comparison. For production packaging, see the [production deployment guide](docs/production.md) and [Helm chart](deploy/helm/qdrant-es-gateway). Versioned container images are published to `ghcr.io/dstockton/qdrant-es-gateway`; releases include an SPDX SBOM, image provenance, SBOM attestation, and vulnerability-scan results. ## Five-minute quickstart ```bash docker compose up --build curl -X PUT localhost:9200/products -H content-type:application/json -d '{"mappings":{"properties":{"title":{"type":"text"},"description":{"type":"text"},"brand":{"type":"keyword"},"price":{"type":"float"}}}}' curl -X POST localhost:9200/products/_bulk -H content-type:application/x-ndjson --data-binary $'{"index":{"_id":"a1"}}\n{"title":"Wireless headphones","description":"Noise cancelling over-ear headset","brand":"Acme","price":99}\n' curl -s localhost:9200/products/_search -H content-type:application/json -d '{"query":{"match":{"title":"wireless headphones"}},"size":10}' ``` The root endpoint includes `X-Elastic-Product: Elasticsearch` and an 8.x-compatible version shape so ordinary official clients can connect. The product name and supported surface remain intentionally explicit. ## Compatibility snapshot | Capability | Status | Notes | |---|---:|---| | Root, cluster health, healthz/readyz | ✅ | Health reflects Qdrant connectivity | | Index lifecycle and mappings | ✅ | Mapping metadata is durable in SQLite | | `_refresh`, `_open`, `_close`, basic `_settings` lifecycle calls | ⚠️ | Accepted for client/application lifecycle compatibility; Qdrant remains continuously available and analyzer/settings semantics are not emulated | | CRUD, `_create`, and `_update` doc subset | ✅ | Standalone index reports created vs updated; `_create` rejects existing IDs; partial `doc`, no-op detection, `doc_as_upsert`, and separate `upsert` sources supported; scripts rejected | | `_bulk` index/create/update/delete | ✅ | POST/PUT NDJSON; index writes stay batched, and update/delete require explicit document IDs | | `_msearch` | ✅ | NDJSON header/query pairs; each sub-search uses the same client request shape | | `match`, `match_phrase` | ⚠️ | Native Qdrant BM25; phrase is lexical, not Lucene-identical | | `multi_match` with boosts | ⚠️ | One named sparse representation per text field, merged in gateway | | `term`, `terms`, `range`, `exists`, `ids` | ✅ | Qdrant payload filters | | bool must/filter/must_not/should | ✅ | Filter-only `should` follows Elasticsearch's default `minimum_should_match`; optional clauses do not boost Qdrant scores | | from/size, source filtering | ✅ | Supports `_source:false`, includes, and excludes; deep pagination is capped | | search_after | ⚠️ | Explicit sort fields and returned `sort` cursors within the configured candidate window; stable while that result set is unchanged | | field sorting | ⚠️ | String, `{"field":"asc"}`, and `{"field":{"order":"desc"}}` forms; sorts at least `MAX_PAGE_SIZE` candidates, so Qdrant-native ordering is preferred for large data | | terms aggregations | ⚠️ | Native Qdrant facet over indexed keyword fields only | | aliases | ⚠️ | Durable unfiltered single-target aliases route CRUD and search; remove+add switching is atomic, while multi-index and unsupported alias-option requests fail instead of silently changing semantics | | regexp, wildcard, prefix | ⚠️ | Gateway-side Rust matching in positive must/filter clauses; scans candidate documents and is not suitable for unbounded high-cardinality pattern queries | | `dis_max`, `match_bool_prefix`, fuzzy-shaped queries | ⚠️ | Accepted through the lexical path; ranking and typo/prefix behavior are approximate | | `post_filter`, min/max/filter aggregations | ⚠️ | Supported for simple clauses over bounded retrieved candidates | | highlighting, score explanations, query profiling, per-hit version/sequence metadata, scripts, scroll, arbitrary aggs | ❌ | Clear structured 400 errors | See [docs/compatibility.md](docs/compatibility.md) for semantic differences and [docs/architecture.md](docs/architecture.md) for the design. Measured storage trade-offs for the two-collection projection are documented in [docs/storage-findings.md](docs/storage-findings.md). For a deterministic, endpoint-only application workflow covering catalogue search, facets, source responses, updates, deletes, and pattern queries, see [validation/README.md](validation/README.md). The published application-target research and staged Spinscale/AWS Retail validation plan are in [docs/application-validation.md](docs/application-validation.md). ## Configuration Copy `.env.example` to `.env`. `QDRANT_URL`, `QDRANT_API_KEY`, `QDRANT_REPLICATION_FACTOR`, `QDRANT_CONNECT_TIMEOUT_MS`, `QDRANT_REQUEST_TIMEOUT_MS`, `ASYNC_WRITE_QUEUE`, `LISTEN_ADDR`, `LOG_LEVEL`, `ES_COMPAT_VERSION`, and `COMPATIBILITY_ANALYTICS` are the important settings. Set `QDRANT_REPLICATION_FACTOR` when creating indices in a distributed Qdrant cluster; it is applied to both the searchable and durable document collections. Secrets are only sent as the Qdrant `api-key` header and are never logged. For write-heavy catalogues, set `DOCUMENT_PROJECTION=true`. Each ES index then gets a second Qdrant collection named `es__documents`. It has no payload indexes and is the durable source for GETs and full `_source` responses; the normal collection remains the searchable projection. Set `ASYNC_SEARCH_PROJECTION=true` to wait for the source write but let the search projection converge with Qdrant's asynchronous operation acknowledgement. This preserves a stateless gateway tier, but production deployments should pair it with a durable projection worker/reconciliation process rather than relying on an in-process queue. Fields used for filters or sorts remain mirrored in the search projection; unmapped metadata-only updates avoid sparse-index work. ## Official client example ```python from elasticsearch import Elasticsearch client = Elasticsearch("http://localhost:9200") client.index(index="products", id="a2", document={"title": "USB-C charger"}) print(client.search(index="products", query={"match": {"title": "charger"}})) ``` The SDK is not required by the gateway. It is a compatibility target; mappings and unsupported features should be checked against the matrix before migration. The request-level audit for the AWS Retail Demo Store and Spinscale catalogue app is in [docs/demo-app-compatibility.md](docs/demo-app-compatibility.md). ## Production readiness and launch The repository includes CI, multi-architecture GHCR publishing, Helm packaging, non-root container defaults, Dependabot updates, SBOM generation, signed attestations, and Trivy scanning. ## Development ```bash make test make lint docker compose up --build make integration-test ``` The metadata database is gateway-owned and should be persisted alongside the deployment. Qdrant remains the source of document and search state. The gateway decodes each URL path segment before deterministically mapping `(index, Elasticsearch _id)` to a UUID-shaped Qdrant point ID and stores the original `_id` in payload, so encoded slashes, arbitrary Unicode, and long IDs survive restarts without an in-memory lookup. With `DOCUMENT_PROJECTION=true`, the gateway retrieves search hits' sources in one batch from the document collection, avoiding an N+1 fetch pattern. ## Honest limitations Scores are Qdrant BM25 scores, not Lucene scores. Refresh/version/sequence semantics are approximations. Analyzer configuration, arbitrary aggregations, deep scored pagination, nested/parent-child documents, scripts, and Elasticsearch security APIs are intentionally outside the MVP. Unsupported requests fail loudly rather than silently dropping clauses. License: Apache-2.0.