Assessor_2.0/server/regmap/qdrant-client.ts

125 lines
4.3 KiB
TypeScript

// 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<string, unknown>;
}
export interface QdrantSearchHit {
id: string | number;
score: number;
payload: Record<string, unknown>;
}
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<Response> {
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<QdrantCollectionInfo> {
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<void> {
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<void> {
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<QdrantSearchHit[]> {
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<string, unknown> }> }
| null;
const hits = Array.isArray(payload?.result) ? payload!.result : [];
return hits.map(hit => ({ id: hit.id, score: hit.score, payload: hit.payload ?? {} }));
}
}