// Minimal Qdrant REST client (backend-only, dependency-free). Only the // operations the ingestion script and retrieval service need are implemented. // // Never logs payload text or vector values. export interface QdrantClientConfig { url: string; apiKey?: string; collection: string; requestTimeoutMs: number; } export type QdrantDistance = 'Cosine' | 'Dot' | 'Euclid'; export interface QdrantCollectionInfo { exists: boolean; vectorSize?: number; distance?: string; } export interface QdrantPoint { id: string | number; vector: number[]; payload: Record; } export interface QdrantSearchHit { id: string | number; score: number; payload: Record; } export class QdrantError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'QdrantError'; } } export class QdrantClient { constructor(private readonly config: QdrantClientConfig) {} private async request(path: string, method: string, body?: unknown): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); try { return await fetch(`${this.config.url}${path}`, { method, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...(this.config.apiKey ? { 'api-key': this.config.apiKey } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); } catch (error) { const aborted = error instanceof Error && error.name === 'AbortError'; throw new QdrantError(aborted ? 'qdrant_timeout' : 'qdrant_unreachable', aborted ? 'Qdrant hat nicht rechtzeitig geantwortet.' : 'Qdrant ist nicht erreichbar.'); } finally { clearTimeout(timeout); } } async collectionInfo(): Promise { const response = await this.request(`/collections/${encodeURIComponent(this.config.collection)}`, 'GET'); if (response.status === 404) return { exists: false }; if (!response.ok) { throw new QdrantError('qdrant_http_error', `Qdrant antwortete mit HTTP ${response.status}.`); } const payload = await response.json().catch(() => null) as | { result?: { config?: { params?: { vectors?: { size?: number; distance?: string } } } } } | null; const vectors = payload?.result?.config?.params?.vectors; return { exists: true, vectorSize: vectors?.size, distance: vectors?.distance }; } async ensureCollection(size: number, distance: QdrantDistance): Promise { const info = await this.collectionInfo(); if (info.exists) { if (info.vectorSize !== undefined && info.vectorSize !== size) { throw new QdrantError('dimension_mismatch', `Die Qdrant-Collection hat Vektorgröße ${info.vectorSize}, erwartet wurden ${size}.`); } return; } const response = await this.request( `/collections/${encodeURIComponent(this.config.collection)}`, 'PUT', { vectors: { size, distance } } ); if (!response.ok) { throw new QdrantError('qdrant_create_failed', `Die Qdrant-Collection konnte nicht angelegt werden (HTTP ${response.status}).`); } } async upsertPoints(points: QdrantPoint[]): Promise { if (points.length === 0) return; const response = await this.request( `/collections/${encodeURIComponent(this.config.collection)}/points?wait=true`, 'PUT', { points } ); if (!response.ok) { throw new QdrantError('qdrant_upsert_failed', `Qdrant-Upsert schlug fehl (HTTP ${response.status}).`); } } async search(vector: number[], limit: number): Promise { const response = await this.request( `/collections/${encodeURIComponent(this.config.collection)}/points/search`, 'POST', { vector, limit, with_payload: true } ); if (!response.ok) { throw new QdrantError('qdrant_search_failed', `Qdrant-Suche schlug fehl (HTTP ${response.status}).`); } const payload = await response.json().catch(() => null) as | { result?: Array<{ id: string | number; score: number; payload?: Record }> } | null; const hits = Array.isArray(payload?.result) ? payload!.result : []; return hits.map(hit => ({ id: hit.id, score: hit.score, payload: hit.payload ?? {} })); } }