125 lines
4.3 KiB
TypeScript
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 ?? {} }));
|
|
}
|
|
}
|