1
18
19import { createHash } from "node:crypto";
20
21import type { EmbeddingProvider } from "./types.js";
22
23
24export const STUB_EMBEDDING_DIMENSION = 64;
25
26
27export const STUB_VECTOR_MODEL = "stub-v1";
28
29
33export function stubEmbed(text: string, dimension: number = STUB_EMBEDDING_DIMENSION): number[] {
34 const vec = new Array<number>(dimension).fill(0);
35 const s = text.toLowerCase().replace(/\s+/g, " ").trim();
36
37 for (let i = 0; i < s.length - 2; i++) {
38 const gram = s.slice(i, i + 3);
39 const h = hashBucket(gram, dimension);
40 vec[h]! += 1;
41 }
42 for (const word of s.split(" ")) {
43 if (word.length === 0) continue;
44 const h = hashBucket("w:" + word, dimension);
45 vec[h]! += 2;
46 }
47 const norm = Math.sqrt(vec.reduce((acc, v) => acc + v * v, 0));
48 if (norm === 0) return vec;
49 return vec.map((v) => v / norm);
50}
51
52function hashBucket(key: string, dimension: number): number {
53 const digest = createHash("sha256").update(key, "utf8").digest();
54 let h = 0;
55 for (let i = 0; i < 8; i++) h = (h * 256 + digest[i]!) >>> 0;
56 return h % dimension;
57}
58
59
64export class StubEmbeddingProvider implements EmbeddingProvider {
65 readonly model: string;
66 readonly dimension: number;
67 constructor(dimension: number = STUB_EMBEDDING_DIMENSION) {
68 this.dimension = dimension;
69 this.model = STUB_VECTOR_MODEL;
70 }
71 async embed(texts: string[]): Promise<number[][]> {
72 return texts.map((t) => stubEmbed(t, this.dimension));
73 }
74}
75
76
77export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
78 if (a.length !== b.length) {
79 throw new Error(`dimension mismatch: ${a.length} != ${b.length}`);
80 }
81 let dot = 0;
82 let na = 0;
83 let nb = 0;
84 for (let i = 0; i < a.length; i++) {
85 dot += a[i]! * b[i]!;
86 na += a[i]! * a[i]!;
87 nb += b[i]! * b[i]!;
88 }
89 if (na === 0 || nb === 0) return 0;
90 return dot / (Math.sqrt(na) * Math.sqrt(nb));
91}
92