96 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
};
|
|
}
|