01-infrastructurewave: W1filled23 citations

Qdrant — Vector Search Infrastructure

Audiences: developer, internal

Qdrant — Vector Search Infrastructure

Qdrant is the embedded vector database that powers semantic search across patient records, clinical notes, and document content — turning free-text clinical artefacts into similarity-searchable knowledge without leaving the clinic's data perimeter.

Business Purpose

Clinical text — encounter notes, intake answers, scanned and OCR'd documents — is the highest-value, lowest-structured data a clinic produces. Keyword search alone misses synonyms, German↔English variants, and clinical paraphrasing. Qdrant stores per-tenant vector embeddings of this text so the platform can answer "what other patients presented with similar symptoms?", retrieve the closest documents for a TUCAN AI prompt (RAG), and let doctors find related encounters by meaning, not exact phrase. Because Qdrant runs as a container on the same Docker network as ddx-api, all of this happens on-prem (no patient embeddings leave the clinic's LAN) — a critical buying point for German clinics with strict data-residency requirements.

Audiences

  • Investor: On-prem vector search is the moat against cloud-first competitors. Embedding generation runs against Dudoxx's self-hosted CUDA embedder, and the resulting vectors are stored in a container the clinic owns. No patient embeddings touch a third-party SaaS.
  • Clinical buyer (doctor/nurse/receptionist): Invisible to clinicians directly — what they see is the deepsearch and TUCAN AI features that "just know" related cases without needing exact keyword matches.
  • Developer/partner: Qdrant lives at localhost:6333 (HTTP, QDRANT_HTTP_PORT) and :6334 (gRPC, QDRANT_GRPC_PORT) — 6xxx dev band; compose defaults 15333/15334 are overridden by .env.docker. NestJS talks to it via the typed QdrantService (ddx-api/src/platform/qdrant/qdrant.service.ts). Service is the only authorized seam — never wire raw HTTP from a feature module. Note: this Qdrant instance is separate from the DDX RAG v3 sidecar (~/.claude/rag-sidecar/), which runs on port 19333 and indexes developer documentation, not clinical data.
  • Internal (ops/support): One container, one named volume, snapshot directory at ./snapshots. Memory cap 4 GB, CPU cap 2.0; tune via QDRANT_MEMORY_LIMIT / QDRANT_CPU_LIMIT. Collections are per-tenant and per-surface (e.g. clinic-{slug}-encounters).

Architecture

On the ddx-001-network, ddx-001-qdrant-1 (qdrant:v1.16.3):

SurfaceBinding
HTTP:6333 (host, QDRANT_HTTP_PORT) → container :6333
gRPC:6334 (host, QDRANT_GRPC_PORT) → container :6334
Volumeddx-001-qdrant-data-1/qdrant/storage
Snapshots./snapshots/qdrant/snapshots

Two consumers reach it over HTTP:

ConsumerTransportSurfaces
NestJS (ddx-api :6100)HTTP (axios-free QdrantClient)QdrantService (typed adapter) · QdrantController (admin CRUD)
DeepsearchControllerHTTP/qdrant/search proxy

Architecture anchor: coding_context/ddx-hms-context.md for the broader HMS service topology. Qdrant fits into the "AI platform" lane alongside PostgreSQL ddx_api_ai and the LiteLLM router.

Tech Stack & Choices

  • Engine: Qdrant v1.16.3 (pinned via QDRANT_IMAGE in ddx-docker-qdrant/docker-compose.yml:16). Qdrant was chosen over pgvector for two reasons: filterable payloads at scale (multi-tenant filtering by organizationId is first-class), and predictable memory behaviour under HNSW workloads.
  • Client: NestJS uses the official @qdrant/js-client-rest package. The client is constructed once in QdrantService and injected by DI everywhere else.
  • Config: production.yaml mounted read-only at docker-compose.yml:39. Environment overrides (QDRANT__SERVICE__API_KEY, QDRANT__STORAGE__OPTIMIZERS__MEMMAP_THRESHOLD_KB) are set via env on the container (docker-compose.yml:25-30) — Qdrant's underscore-pair YAML override convention.
  • API key: QDRANT__SERVICE__API_KEY=ddx_qdrant_key_1 (default; rotate in production). Every request from QdrantService includes the api-key header.
  • Memory tuning: MAX_SEARCH_THREADS=0 (auto-scale to host CPUs), MEMMAP_THRESHOLD_KB=20480 (mmap segments larger than 20 MB to reduce RSS).
  • Resource limits: 4 GB memory cap, 2.0 CPU cap (docker-compose.yml:43-45) — production-tuned for a single-clinic deployment; multi-clinic SaaS instances scale vertically by raising these.
  • Why not pgvector: pgvector inside ddx_api_main would couple vector lifecycle to relational migrations, complicate backups (vectors are large), and degrade query planner choices for OLTP workloads. Separation keeps each engine optimal at its job.

Data Flow

Write path (embedding upsert):

  1. A feature module (e.g. clinical-notes, documents) calls Dudoxx's CUDA embedder at embedder.home.dudoxx.com/v1/embeddings to obtain a vector for free-text content.
  2. Module invokes QdrantService.upsertVectors(collection, points) — see ddx-api/src/platform/qdrant/qdrant.service.ts:63. Points carry id (the Prisma/FHIR resource ID), the vector, and a payload with organizationId for tenant filtering.
  3. QdrantService issues a single PUT /collections/{name}/points to the container.
  4. No SSE event is published at this layer — embedding writes are background work; UI surfaces only depend on search results.

Read path (similarity search):

  1. Either the public admin controller QdrantController (qdrant.controller.ts:25/qdrant/... routes) or the deepsearch controller (deepsearch.controller.ts — clinical search) receives a search query.
  2. The query text is embedded via the same CUDA embedder, then handed to QdrantService.searchVectors(collection, vector, filter) — see qdrant.service.ts:115.
  3. A filter clause scoped by organizationId enforces tenant isolation at the engine level; results never cross tenants even if a developer forgets a where-clause higher up.
  4. The service post-processes results in extractTopScores (qdrant.service.ts:333) to dedupe near-identical hits and return only the top-N by score.
  5. Caller maps point.id back to a Prisma/FHIR row and returns hydrated records to the UI.

Collections (current, per-tenant where relevant):

  • patient_embeddings — vectorised intake answers and demographic free-text.
  • clinical_notes — encounter notes and assessment narratives.
  • medical_records — OCR'd document content.
  • chat_history — TUCAN/voice conversation snippets for retrieval.

(Exact list per deployment is discoverable via GET /collections against the container — qdrant.service.ts:275.)

Implicated Code

≥3 file:line citations required. Counts: 9 below.

  • ddx-docker-qdrant/docker-compose.yml:15qdrant service definition (qdrant:v1.16.3, container ddx-001-qdrant-1).
  • ddx-docker-qdrant/docker-compose.yml:33 — host port mapping (${QDRANT_HTTP_PORT:-15333}:6333 HTTP + ${QDRANT_GRPC_PORT:-15334}:6334 gRPC (live 6333/6334)).
  • ddx-docker-qdrant/docker-compose.yml:25QDRANT__SERVICE__API_KEY env injection that every NestJS request must match.
  • ddx-api/src/platform/qdrant/qdrant.service.ts:15QdrantService class (lines 15–369), the only authorized seam between ddx-api and the Qdrant container.
  • ddx-api/src/platform/qdrant/qdrant.service.ts:22 — constructor (lines 22–57) constructs QdrantClient once with the API key from ConfigService.
  • ddx-api/src/platform/qdrant/qdrant.service.ts:63upsertVectors() (lines 63–109) — write path used by every embedding producer.
  • ddx-api/src/platform/qdrant/qdrant.service.ts:115searchVectors() (lines 115–211) — read path used by deepsearch + TUCAN RAG.
  • ddx-api/src/platform/qdrant/qdrant.service.ts:333extractTopScores() (lines 333–368) — dedup + top-N selection used by all callers.
  • ddx-api/src/platform/qdrant/qdrant.controller.ts:25QdrantController admin endpoints (upsert/search/list/scroll/create/delete).
  • ddx-api/src/platform/qdrant/qdrant.module.ts:7QdrantModule declares the QdrantService provider and exports it for cross-module reuse.

Business → Technical match. Business outcome: a doctor reviewing a complex case asks the assistant "show me other patients with similar presentations" and receives a ranked list of related encounters from the same clinic — not from any other tenant. Technical mechanism: the encounter note is embedded via the CUDA embedder, upserted with payload.organizationId=<orgId> (qdrant.service.ts:63), and search-time filtering enforces the tenant scope (qdrant.service.ts:115). Tenant isolation is a Qdrant filter, not just a Postgres where-clause — so a missing where-clause higher up cannot leak cross-tenant results.

Operational Notes

  • Separate from DDX RAG v3 sidecar: The Qdrant instance documented here (port 6333) is the HMS clinical vector store. The developer-facing DDX RAG v3 sidecar runs an independent Qdrant on port 19333 (~/.claude/rag-sidecar/) and indexes source code + docs, not patient data. Do not cross the streams.
  • Collections-per-clinic is the safe pattern when one tenant produces enough vectors to dominate HNSW lookups; collections-per-surface (clinical_notes, patient_embeddings) is fine when volumes are balanced and tenant filtering is good enough.
  • API key rotation: change QDRANT__SERVICE__API_KEY in .env.docker and the matching QDRANT_API_KEY consumed by ddx-api; restart both containers. NestJS reads it at module init via ConfigService in qdrant.service.ts:22-57.
  • Snapshots: directory mounted at ./snapshots; trigger via Qdrant's POST /collections/{name}/snapshots. Snapshots are how we move embeddings between environments without re-running the embedder.
  • Healthcheck: docker-compose.yml:58-63 greps /proc/net/tcp for the listening port — Qdrant's own /healthz endpoint is also available at :6333/healthz.
  • Memory pressure: if RSS exceeds the 4 GB cap, lower MEMMAP_THRESHOLD_KB so more segments memory-map (lower RSS but more I/O). Larger threshold → more in-RAM, faster search.
  • Validation commands:
    • curl http://localhost:6333/healthzhealthz check passed
    • curl -H "api-key: $QDRANT_API_KEY" http://localhost:6333/collections | jq → enumerate collections
    • docker logs -f ddx-001-qdrant-1 → boot trace
  • PostgreSQL — Multi-Schema Database Infrastructurepoint.id in Qdrant is the Prisma/FHIR row id; embeddings are useless without the relational metadata.
  • MinIO — Object Storage Infrastructure — document content (the binary) lives in MinIO; the OCR'd text is what gets embedded into Qdrant.
  • For developer-side semantic search across the codebase, see ~/.claude/rag-sidecar/USAGE.md (different Qdrant, port 19333) — explicitly out of scope here.