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

96 lines
3.5 KiB
TypeScript

// OpenAI-compatible embedding client (backend-only). Targets the explicitly
// configured internal endpoint (llama.cpp / Ollama / LM Studio / LiteLLM).
// Vectors are L2-normalized so a plain dot product equals cosine similarity.
//
// Never logs input text, vectors, or endpoint payloads.
export interface EmbeddingClientConfig {
baseUrl: string;
model: string;
apiKey?: string;
dimensions: number;
queryPrefix?: string;
passagePrefix?: string;
requestTimeoutMs: number;
}
export type EmbeddingKind = 'query' | 'passage';
/** Safe, stable error codes — carry no sensitive content. */
export class EmbeddingError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'EmbeddingError';
}
}
export interface EmbeddingClient {
embed(texts: string[], kind: EmbeddingKind): Promise<number[][]>;
}
function normalize(vector: number[]): number[] {
let sumSquares = 0;
for (const value of vector) sumSquares += value * value;
const magnitude = Math.sqrt(sumSquares);
if (!Number.isFinite(magnitude) || magnitude === 0) return vector;
return vector.map(value => value / magnitude);
}
export function createEmbeddingClient(config: EmbeddingClientConfig): EmbeddingClient {
const prefixFor = (kind: EmbeddingKind): string =>
(kind === 'query' ? config.queryPrefix : config.passagePrefix) ?? '';
return {
async embed(texts: string[], kind: EmbeddingKind): Promise<number[][]> {
if (texts.length === 0) return [];
const prefix = prefixFor(kind);
const input = texts.map(text => `${prefix}${text}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
let response: Response;
try {
response = await fetch(`${config.baseUrl}/embeddings`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {})
},
body: JSON.stringify({ model: config.model, input })
});
} catch (error) {
const aborted = error instanceof Error && error.name === 'AbortError';
throw new EmbeddingError(aborted ? 'embedding_timeout' : 'embedding_unreachable',
aborted ? 'Der Embedding-Dienst hat nicht rechtzeitig geantwortet.' : 'Der Embedding-Dienst ist nicht erreichbar.');
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new EmbeddingError('embedding_http_error', `Embedding-Dienst antwortete mit HTTP ${response.status}.`);
}
let payload: { data?: Array<{ embedding?: number[] }> };
try {
payload = await response.json() as typeof payload;
} catch {
throw new EmbeddingError('embedding_invalid_response', 'Die Antwort des Embedding-Dienstes war kein gültiges JSON.');
}
const rows = Array.isArray(payload.data) ? payload.data : [];
if (rows.length !== texts.length) {
throw new EmbeddingError('embedding_count_mismatch', 'Der Embedding-Dienst lieferte eine andere Anzahl an Vektoren als angefragt.');
}
return rows.map(row => {
const vector = Array.isArray(row.embedding) ? row.embedding : [];
if (vector.length !== config.dimensions) {
throw new EmbeddingError('dimension_mismatch',
`Der Embedding-Dienst lieferte ${vector.length} Dimensionen, erwartet wurden ${config.dimensions}.`);
}
return normalize(vector);
});
}
};
}