DeepSearch — Semantic Patient Data Search
Audiences: doctor, developer, investor
A separate Next.js 16 app on port 6220 — not a route inside ddx-web — that fronts the Qdrant vector store: clinicians ask a natural-language question, the app returns ranked chunks from OCR-ingested documents with patient and diagnosis context. Same Auth.js session, same BFF pattern, dedicated minimal shell.
Business Purpose
After a clinic has ingested years of scanned letters, lab reports, and imaging findings (through doxing OCR + chunking), the clinical question becomes "where did we see this before?". Keyword search rarely works on clinical prose ("hyperintensity in the left frontoparietal region" vs "white-matter lesion left front"). DeepSearch is the search-bar UX over the project's RAG pipeline: a vector query becomes ranked semantic chunks, scored, language-tagged, ICD-coded, attached to a patient name and source file — clinicians read three or four chunks and know whether to open the underlying document.
It is also a deliberately isolated app (port 6220, separate deploy) for two reasons. (1) Search is high-RPS and the team wanted independent scaling from the main HMS portal. (2) The reference architecture for a sandbox/embeddable Dudoxx surface — a Next.js shell that consumes the same Auth.js cookie and BFF gateway but ships independently.
Audiences
- Investor: a worked example of the AI moat — once the OCR pipeline has ingested the practice's archive, semantic search is the daily "wow" demo for clinicians; "find every mention of methotrexate dosing questions in the last two years" returns instantly.
- Doctor: the search bar for clinical recall. Quick actions ("Patient History", "Medication Review", "Treatment Guidelines", "Lab Results") seed common queries.
- Developer/partner: shows how to build a second Dudoxx Next.js app that shares the same Auth.js cookie + BFF gateway pattern without living inside ddx-web. Useful template for the partner-portal idea and any future dedicated surface.
- Internal (ops/support): port 6220,
dudoxx-deepsearch.{trigram}.dudoxx.comvhost convention, depends on Qdrant + ddx-api OCR endpoints.
Architecture
Separate app, same identity
ddx-deepsearch/ ← Next.js 16.2.1, port 6220 ├── src/ │ ├── proxy.ts → minimal: locale + session check │ ├── auth.ts → Auth.js v5 (shares ddx-web cookie) │ ├── app/ │ │ ├── [locale]/ │ │ │ ├── auth/ → login forms (when no session) │ │ │ ├── (shell)/ → DeepSearchShell route group │ │ │ │ ├── layout.tsx → mounts <DeepSearchShell> │ │ │ │ ├── page.tsx → root search UX │ │ │ │ ├── dashboard/ → stats + onboarding │ │ │ │ ├── patients/ → patient-scoped search │ │ │ │ │ ├── page.tsx → patient list │ │ │ │ │ └── [id]/ → per-patient documents │ │ │ │ ├── history/, saved/, help/, settings/ │ │ ├── api/ │ │ │ ├── auth/ → Auth.js routes │ │ │ └── v1/[...path]/ → BFF catch-all (browser → ddx-api) │ ├── components/ │ │ ├── search/SearchClient.tsx → main vector-search UX │ │ ├── patients/ → 13 patient/document widgets │ │ ├── DeepSearchShell.tsx → minimal header + side nav + footer │ ├── lib/ │ │ ├── api/ → chunk-service, gateway-headers │ │ └── sse/ → use-job-events + use-extraction-events
Minimal proxy.ts
ddx-deepsearch/src/proxy.ts:38-59 is half the size of ddx-web/src/proxy.ts:
locale extraction, isAuthPath short-circuit, then hasSessionCookie —
no trigram lookup, no PROTECTED_ROUTES list, no header injection. There
is no per-tenant trigram resolution here because the Auth.js session
already carries organizationId and the BFF builds the gateway headers
from that.
if (!hasSessionCookie(request)) {
const loginUrl = new URL(`/${locale}/auth/login`, request.url);
loginUrl.searchParams.set('callbackUrl', pathWithoutLocale);
return NextResponse.redirect(loginUrl);
}
BFF catch-all — app/api/v1/[...path]/route.ts
ddx-deepsearch/src/app/api/v1/[...path]/route.ts:26-131 is the same
Trusted Gateway pattern as ddx-web, simplified:
buildGatewayHeaders(user, accessToken)fromlib/api/gateway-headers.ts— sameX-API-Key,X-Gateway-User-*header set as ddx-web.BACKEND_URLresolves to ddx-api (port 6100).- Binary-safe body forwarding (
request.arrayBuffer()—:59). - SSE pass-through (
:81-91): preservestext/event-stream, setsCache-Control: no-cache, no-transform,Connection: keep-alive,X-Accel-Buffering: no— the same convention as ddx-web. - Document preview iframe support (
:99-108): for PDF/image/audio/ video content types, removesX-Frame-Optionsand setsContent-Security-Policy: frame-ancestors 'self'so the in-appDocumentPreviewPanelcan embed.
Search UX — SearchClient.tsx
ddx-deepsearch/src/components/search/SearchClient.tsx:11-120 is the
flagship client component:
- 500ms debounced input →
searchChunks(query, 'ddx-ocr-chunks', { limit: 20, scoreThreshold: 0.3 }). - Four quick actions seed the query (
QUICK_ACTIONS—:8:patientHistory,medicationReview,treatmentGuidelines,labResults). - Loading / error / empty / results states all on a single component
(
:84-118).
Results are DocumentChunk records (chunk-service.ts:14-31):
qdrant_id, chunk_index, text, contextual_prefix, score,
source_file, task_id, optional session_id, document_type,
patient_name, diagnoses (array), icd_codes (array), language,
collection.
chunk-service.ts — client-side wrapper
ddx-deepsearch/src/lib/api/chunk-service.ts:1-31 is 'use client' and
imports apiClient (./client). It hits ddx-api's OCR chunk endpoints
via the BFF — browser → /api/v1/qdrant/search/... → ddx-deepsearch
catch-all → ddx-api → Qdrant.
Patient-scoped variants
ddx-deepsearch/src/components/patients/ has 13 components:
PatientsListClient, PatientCard, PatientBanner,
PatientDocumentsClient, DocumentCard, DocumentDetailClient,
DocumentPreviewPanel, ChunksTabContent, DocumentActions,
DocumentFilters, JobProgressBar, MediaPlayer, PatientAvatar. The
(shell)/patients/[id]/ route shows a per-patient document explorer
with chunk filtering, preview, and a job-progress bar for re-OCR.
Real-time updates — SSE
ddx-deepsearch/src/lib/sse/:
use-job-events.ts— subscribe to ingestion job progress (re-OCR, re-index).use-extraction-events.ts— subscribe to per-document extraction events (chunk insertion, embedding completion).
Both use EventSource('/api/v1/sse/...') — the BFF preserves
text/event-stream end-to-end.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16.2.1 + React 19.2.4 + Tailwind 4.2.2 | Same major versions as ddx-web; allows shared snippets, deps, and CI. |
| App separation | Standalone ddx-deepsearch/ repo dir, port 6220 | Independent deploy + scaling; search is high-RPS and benefits from separate horizontal scale. |
| Auth | Auth.js v5 same cookie family | One login, two apps. Cookie naming via getDdxAuthSessionCookieCandidates() keeps both in sync. |
| BFF | Catch-all app/api/v1/[...path]/route.ts | Same Trusted Gateway pattern; ddx-deepsearch never exposes ddx-api directly. |
| Vector store | Qdrant via ddx-api qdrant module | Browser never touches Qdrant; everything proxies. |
| Search collection | ddx-ocr-chunks default (SearchClient.tsx:9) | Single named collection; chunks are language- and ICD-tagged at ingestion. |
| Score threshold | 0.3 default (SearchClient.tsx:28) | Conservative — high-precision; users can request more recall via filters. |
| Locales | en/fr/de via next-intl 4.8.3 | Mirrors ddx-web; all UI strings translated. |
| Real-time | SSE via EventSource | Same SSE contract as ddx-web; preserved end-to-end through both BFF gateways. |
| State | TanStack Query 5.95.2, nuqs 2.4.1 | Search/filter URL state via nuqs; pagination/caching via Query. |
Rejected: building DeepSearch as a route inside ddx-web (would contaminate the main app's bundle with heavy patient-document viewers and a vector-search dependency tree); shipping a direct browser → Qdrant client (would expose the Qdrant API key and force Qdrant CORS); using a separate auth pool (would double-prompt users).
Data Flow
(1) Search a chunk
- Doctor logs in to ddx-web (
acm.dudoxx.com); Auth.js sets the__Secure-...-ddx-auth-session-tokencookie scoped to.dudoxx.com. - Doctor navigates to
acm-deepsearch.dudoxx.com(or the wired ddx-web shortcut); the cookie is sent. ddx-deepsearch/src/proxy.ts:50-56sees a session cookie and proceeds.- The shell renders; doctor types a query in
SearchClient. - After 500ms debounce,
searchChunks('white matter lesion', 'ddx-ocr-chunks', { limit: 20, scoreThreshold: 0.3 }). - Browser POSTs to
/api/v1/qdrant/search/...(same-origin). ddx-deepsearch/src/app/api/v1/[...path]/route.tsbuilds theX-Gateway-User-*header set and forwards to ddx-api.- ddx-api
QdrantControllerruns the vector search againstddx-ocr-chunks, joins patient + ICD + language metadata, returns ranked chunks. SearchClientrendersSearchResultCardrows with score, snippet, patient name, ICD codes.
(2) Open a chunk's source document
- Click → navigate to
/<locale>/patients/<id>(in-app route). (shell)/patients/[id]/page.tsxserver-fetches the patient + ranked documents via the BFF.DocumentPreviewPanelrequests the binary preview via/api/v1/documents/<id>/preview— BFF removesX-Frame-Options(route.ts:99-108) and the panel embeds the PDF/image inline.
(3) Re-ingest job stream
- Triggering a re-OCR opens an SSE channel
/api/v1/jobs/<jobId>/events. use-job-eventssubscribes; chunks insert into Qdrant as they process; theJobProgressBaradvances.- After completion, the search results live-update.
Implicated Code
ddx-deepsearch/src/proxy.ts:38-59— minimal locale + session middleware.ddx-deepsearch/src/proxy.ts:21—AUTH_PATHSexception list.ddx-deepsearch/src/proxy.ts:27-36—hasSessionCookieviagetDdxAuthSessionCookieCandidates().ddx-deepsearch/src/auth.ts— Auth.js v5 setup (shares the ddx-web cookie family).ddx-deepsearch/src/app/[locale]/(shell)/layout.tsx:1-17— shell layout: read session, mount<DeepSearchShell>.ddx-deepsearch/src/app/[locale]/(shell)/dashboard/page.tsx:7-72— greeting + stats + getStarted + quickActions; quick-action links to/,/history,/saved,/help.ddx-deepsearch/src/components/search/SearchClient.tsx:11-120— flagship search UX;QUICK_ACTIONSat:8, debounce at:41, result rendering at:96-110.ddx-deepsearch/src/lib/api/chunk-service.ts:14-44—DocumentChunk,ChunksResponse,SemanticSearchResultinterfaces.ddx-deepsearch/src/app/api/v1/[...path]/route.ts:26-131— BFF catch-all; SSE handling at:81-91; iframe header normalisation at:99-108.ddx-deepsearch/src/components/patients/— 13 patient + document components.ddx-deepsearch/src/lib/sse/use-job-events.ts— ingestion job progress stream.ddx-deepsearch/src/lib/sse/use-extraction-events.ts— per-document extraction events.ddx-deepsearch/package.json:5-9— port 6220, version 0.7.7,next 16.2.1.ddx-api/src/platform/qdrant/— backend vector-search endpoints (consumer of rag-system-indexation artefacts).
Operational Notes
- Port 6220 is canonical —
dudoxx-{trigram}-deepsearch.dudoxx.comvhost convention; PM2 entry must usenext start --port 6220. Conflict with another local Next.js will silently fail to bind. - Cookie must match ddx-web —
getDdxAuthSessionCookieCandidates()is the single source of truth. Auth.js cookie name drift (e.g. moving fromauthjstonext-auth) must propagate to both apps simultaneously or the doctor sees a login screen in DeepSearch only. - No
X-Clinic-IDinjection in proxy — DeepSearch does not do trigram lookup. Tenant routing happens server-side in the BFF (gateway-headers.ts:resolveClinicId(user)); never read the Host header for tenant logic here. - Default collection:
ddx-ocr-chunks—SearchClient.tsx:9hard codes this. If you split collections per language/clinic in rag-system-indexation, surface a UI selector before renaming the constant — silent partial-collection results are a worse UX than a visible dropdown. scoreThreshold: 0.3—SearchClient.tsx:28. Tuning hits two signals: too-high → empty results on legitimate queries (false negatives); too-low → noise. Adjust against a labeled test set.- iframe-preview content-types —
route.ts:99-108only stripsX-Frame-Optionsforapplication/pdf,image/*,audio/*,video/*. New media types must be added explicitly; do not blanket- strip the header. - No Qdrant in the browser —
chunk-service.tsimportsapiClient, not a Qdrant client. Even if Qdrant adds a JS SDK, do not import it into a'use client'file — it would leak the API key and break the Trusted Gateway invariant. - Build vs runtime —
pnpm devruns on port 6220; productionpnpm startreads thebuild/directory. The ddx-web build pitfall (distDirenv-aware) does NOT apply here — DeepSearch uses the default.nextdirectory.
Related Topics
- Multi-Portal Architecture — the eleven-portal app DeepSearch sits adjacent to (same Auth.js cookie, separate deployment).
- Web Proxy Gateway — the Trusted Gateway pattern DeepSearch's BFF replicates.
- RAG System / Indexation —
the pipeline that produces
ddx-ocr-chunks. - Doxing — OCR + chunking ingestion that fills the vector store.
- Document Management — the underlying document records that chunks reference.