|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Pluggable embedding provider for the Turso knowledge adapter. |
| 4 | + * Embedding helpers for the Turso knowledge adapter. |
5 | 5 | * |
6 | | - * Adapters call `embed()` once with the full batch of chunk texts at |
7 | | - * upsert time, and once per query at search time. Implementations are |
8 | | - * responsible for batching / rate-limiting against their upstream API. |
| 6 | + * The interface itself now lives in `@objectstack/spec/contracts` as |
| 7 | + * `IEmbedder` — see that file for the protocol-level contract. |
| 8 | + * |
| 9 | + * This module ships ONLY the deterministic `HashEmbedder` used for |
| 10 | + * tests and offline dev. For real models, install a dedicated |
| 11 | + * embedder plugin: |
| 12 | + * |
| 13 | + * - `@objectstack/embedder-openai` (OpenAI / 阿里通义 / 智谱 / |
| 14 | + * 硅基流动 / 火山 Doubao / |
| 15 | + * MiniMax / Ollama / 任何 |
| 16 | + * OpenAI-shape 兼容端点) |
| 17 | + * |
| 18 | + * Migrating from `@objectstack/knowledge-turso` ≤ 6.6: |
| 19 | + * - `EmbeddingProvider` → `IEmbedder` (`@objectstack/spec/contracts`) |
| 20 | + * - `OpenAIEmbeddingProvider` → `OpenAIEmbedder` (`@objectstack/embedder-openai`) |
| 21 | + * - `HashEmbeddingProvider` → `HashEmbedder` (this file, unchanged behaviour) |
9 | 22 | */ |
10 | | -export interface EmbeddingProvider { |
11 | | - /** Stable id (mostly for logs). */ |
12 | | - readonly id: string; |
13 | | - /** Output vector dimensionality — used to size the `F32_BLOB(N)` column. */ |
14 | | - readonly dimensions: number; |
15 | | - /** Embed a batch of strings. Output order matches input order. */ |
16 | | - embed(texts: string[]): Promise<number[][]>; |
17 | | -} |
18 | 23 |
|
19 | | -export interface OpenAIEmbeddingOptions { |
20 | | - apiKey: string; |
21 | | - /** @default 'text-embedding-3-small' */ |
22 | | - model?: string; |
23 | | - /** Override dimensions (only some models support this). */ |
24 | | - dimensions?: number; |
25 | | - /** Override base URL (Azure / proxy / Ollama-compatible servers). */ |
26 | | - baseUrl?: string; |
27 | | - /** Inject for tests. Defaults to global fetch. */ |
28 | | - fetch?: typeof fetch; |
29 | | -} |
| 24 | +import type { IEmbedder } from '@objectstack/spec/contracts'; |
30 | 25 |
|
31 | 26 | /** |
32 | | - * Known dimensions for OpenAI's first-party embedding models. Used as |
33 | | - * the default when the caller doesn't pass `dimensions` explicitly. |
| 27 | + * @deprecated Use `IEmbedder` from `@objectstack/spec/contracts`. |
| 28 | + * Re-exported here as an alias to ease migration; will be removed in |
| 29 | + * a future major. |
34 | 30 | */ |
35 | | -const OPENAI_DEFAULT_DIMS: Record<string, number> = { |
36 | | - 'text-embedding-3-small': 1536, |
37 | | - 'text-embedding-3-large': 3072, |
38 | | - 'text-embedding-ada-002': 1536, |
39 | | -}; |
40 | | - |
41 | | -/** |
42 | | - * OpenAI-compatible embedding provider. Works against the real OpenAI |
43 | | - * API, Azure OpenAI deployments, and any drop-in compatible server |
44 | | - * (LiteLLM, vLLM, Ollama with the openai shim). |
45 | | - */ |
46 | | -export class OpenAIEmbeddingProvider implements EmbeddingProvider { |
47 | | - readonly id = 'openai'; |
48 | | - readonly dimensions: number; |
49 | | - private readonly model: string; |
50 | | - private readonly baseUrl: string; |
51 | | - private readonly apiKey: string; |
52 | | - private readonly fetchImpl: typeof fetch; |
53 | | - private readonly requestedDims?: number; |
54 | | - |
55 | | - constructor(opts: OpenAIEmbeddingOptions) { |
56 | | - if (!opts.apiKey) throw new Error('OpenAIEmbeddingProvider: apiKey required'); |
57 | | - this.apiKey = opts.apiKey; |
58 | | - this.model = opts.model ?? 'text-embedding-3-small'; |
59 | | - this.baseUrl = (opts.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, ''); |
60 | | - this.fetchImpl = opts.fetch ?? (globalThis.fetch as typeof fetch); |
61 | | - this.requestedDims = opts.dimensions; |
62 | | - this.dimensions = |
63 | | - opts.dimensions ?? OPENAI_DEFAULT_DIMS[this.model] ?? 1536; |
64 | | - if (!this.fetchImpl) { |
65 | | - throw new Error('OpenAIEmbeddingProvider: no fetch available; pass options.fetch'); |
66 | | - } |
67 | | - } |
68 | | - |
69 | | - async embed(texts: string[]): Promise<number[][]> { |
70 | | - if (texts.length === 0) return []; |
71 | | - const body: Record<string, unknown> = { model: this.model, input: texts }; |
72 | | - if (this.requestedDims) body.dimensions = this.requestedDims; |
73 | | - const res = await this.fetchImpl(`${this.baseUrl}/embeddings`, { |
74 | | - method: 'POST', |
75 | | - headers: { |
76 | | - 'content-type': 'application/json', |
77 | | - authorization: `Bearer ${this.apiKey}`, |
78 | | - }, |
79 | | - body: JSON.stringify(body), |
80 | | - }); |
81 | | - if (!res.ok) { |
82 | | - const text = await res.text().catch(() => ''); |
83 | | - throw new Error( |
84 | | - `OpenAI embeddings → ${res.status} ${res.statusText}${text ? `: ${text.slice(0, 200)}` : ''}`, |
85 | | - ); |
86 | | - } |
87 | | - const json = (await res.json()) as { data?: Array<{ embedding: number[] }> }; |
88 | | - const data = json.data ?? []; |
89 | | - if (data.length !== texts.length) { |
90 | | - throw new Error( |
91 | | - `OpenAI embeddings: expected ${texts.length} vectors, got ${data.length}`, |
92 | | - ); |
93 | | - } |
94 | | - return data.map((d) => d.embedding); |
95 | | - } |
96 | | -} |
| 31 | +export type EmbeddingProvider = IEmbedder; |
97 | 32 |
|
98 | 33 | /** |
99 | | - * Deterministic, dependency-free embedding provider for unit tests |
100 | | - * and offline development. Hashes tokens into a fixed-width vector — |
101 | | - * not semantically meaningful, but cosine-distance preserves token |
| 34 | + * Deterministic, dependency-free embedder for unit tests and offline |
| 35 | + * development. Hashes tokens into a fixed-width vector — not |
| 36 | + * semantically meaningful, but cosine-distance preserves token |
102 | 37 | * overlap, which is enough to validate adapter plumbing. |
103 | 38 | */ |
104 | | -export class HashEmbeddingProvider implements EmbeddingProvider { |
| 39 | +export class HashEmbedder implements IEmbedder { |
105 | 40 | readonly id = 'hash'; |
106 | 41 | readonly dimensions: number; |
107 | 42 |
|
108 | 43 | constructor(dimensions = 64) { |
109 | | - if (dimensions < 4) throw new Error('HashEmbeddingProvider: dimensions must be >= 4'); |
| 44 | + if (dimensions < 4) throw new Error('HashEmbedder: dimensions must be >= 4'); |
110 | 45 | this.dimensions = dimensions; |
111 | 46 | } |
112 | 47 |
|
@@ -136,6 +71,9 @@ export class HashEmbeddingProvider implements EmbeddingProvider { |
136 | 71 | } |
137 | 72 | } |
138 | 73 |
|
| 74 | +/** @deprecated Renamed to {@link HashEmbedder}. */ |
| 75 | +export const HashEmbeddingProvider = HashEmbedder; |
| 76 | + |
139 | 77 | function fnv1a(s: string): number { |
140 | 78 | let h = 0x811c9dc5; |
141 | 79 | for (let i = 0; i < s.length; i++) { |
|
0 commit comments