Qdrant — Vector Search Infrastructure
Audiences: developer, internal
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 defaults15333/15334are overridden by.env.docker. NestJS talks to it via the typedQdrantService(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 viaQDRANT_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):
| Surface | Binding |
|---|---|
| HTTP | :6333 (host, QDRANT_HTTP_PORT) → container :6333 |
| gRPC | :6334 (host, QDRANT_GRPC_PORT) → container :6334 |
| Volume | ddx-001-qdrant-data-1 → /qdrant/storage |
| Snapshots | ./snapshots → /qdrant/snapshots |
Two consumers reach it over HTTP:
| Consumer | Transport | Surfaces |
|---|---|---|
NestJS (ddx-api :6100) | HTTP (axios-free QdrantClient) | QdrantService (typed adapter) · QdrantController (admin CRUD) |
DeepsearchController | HTTP | /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 viaQDRANT_IMAGEinddx-docker-qdrant/docker-compose.yml:16). Qdrant was chosen over pgvector for two reasons: filterable payloads at scale (multi-tenant filtering byorganizationIdis first-class), and predictable memory behaviour under HNSW workloads. - Client: NestJS uses the official
@qdrant/js-client-restpackage. The client is constructed once inQdrantServiceand injected by DI everywhere else. - Config:
production.yamlmounted read-only atdocker-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 fromQdrantServiceincludes theapi-keyheader. - 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_mainwould 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):
- A feature module (e.g. clinical-notes, documents) calls Dudoxx's CUDA embedder at
embedder.home.dudoxx.com/v1/embeddingsto obtain a vector for free-text content. - Module invokes
QdrantService.upsertVectors(collection, points)— seeddx-api/src/platform/qdrant/qdrant.service.ts:63. Points carryid(the Prisma/FHIR resource ID), thevector, and a payload withorganizationIdfor tenant filtering. QdrantServiceissues a singlePUT /collections/{name}/pointsto the container.- No SSE event is published at this layer — embedding writes are background work; UI surfaces only depend on search results.
Read path (similarity search):
- 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. - The query text is embedded via the same CUDA embedder, then handed to
QdrantService.searchVectors(collection, vector, filter)— seeqdrant.service.ts:115. - A
filterclause scoped byorganizationIdenforces tenant isolation at the engine level; results never cross tenants even if a developer forgets a where-clause higher up. - The service post-processes results in
extractTopScores(qdrant.service.ts:333) to dedupe near-identical hits and return only the top-N by score. - Caller maps
point.idback 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:15—qdrantservice definition (qdrant:v1.16.3, containerddx-001-qdrant-1).ddx-docker-qdrant/docker-compose.yml:33— host port mapping (${QDRANT_HTTP_PORT:-15333}:6333HTTP +${QDRANT_GRPC_PORT:-15334}:6334gRPC (live 6333/6334)).ddx-docker-qdrant/docker-compose.yml:25—QDRANT__SERVICE__API_KEYenv injection that every NestJS request must match.ddx-api/src/platform/qdrant/qdrant.service.ts:15—QdrantServiceclass (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) constructsQdrantClientonce with the API key fromConfigService.ddx-api/src/platform/qdrant/qdrant.service.ts:63—upsertVectors()(lines 63–109) — write path used by every embedding producer.ddx-api/src/platform/qdrant/qdrant.service.ts:115—searchVectors()(lines 115–211) — read path used by deepsearch + TUCAN RAG.ddx-api/src/platform/qdrant/qdrant.service.ts:333—extractTopScores()(lines 333–368) — dedup + top-N selection used by all callers.ddx-api/src/platform/qdrant/qdrant.controller.ts:25—QdrantControlleradmin endpoints (upsert/search/list/scroll/create/delete).ddx-api/src/platform/qdrant/qdrant.module.ts:7—QdrantModuledeclares theQdrantServiceprovider 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_KEYin.env.dockerand the matchingQDRANT_API_KEYconsumed by ddx-api; restart both containers. NestJS reads it at module init viaConfigServiceinqdrant.service.ts:22-57. - Snapshots: directory mounted at
./snapshots; trigger via Qdrant'sPOST /collections/{name}/snapshots. Snapshots are how we move embeddings between environments without re-running the embedder. - Healthcheck:
docker-compose.yml:58-63greps/proc/net/tcpfor the listening port — Qdrant's own/healthzendpoint is also available at:6333/healthz. - Memory pressure: if RSS exceeds the 4 GB cap, lower
MEMMAP_THRESHOLD_KBso more segments memory-map (lower RSS but more I/O). Larger threshold → more in-RAM, faster search. - Validation commands:
curl http://localhost:6333/healthz→healthz check passedcurl -H "api-key: $QDRANT_API_KEY" http://localhost:6333/collections | jq→ enumerate collectionsdocker logs -f ddx-001-qdrant-1→ boot trace
Related Topics
- PostgreSQL — Multi-Schema Database Infrastructure —
point.idin 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.