shreyask Claude Opus 4.6 commited on
Commit
eb46abf
·
verified ·
1 Parent(s): a6ac99b

add embeddings and vector search modules

Browse files

Embeddings module wraps the embeddinggemma pipeline for query, batch,
and document chunk embedding using Transformers.js feature-extraction.
Vector search implements cosine similarity ranking over embedded chunks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/pipeline/embeddings.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getEmbeddingPipeline } from "./models";
2
+ import { EMBED_QUERY_TEMPLATE, EMBED_DOC_TEMPLATE } from "../constants";
3
+
4
+ /**
5
+ * Embed a single query string using the embeddinggemma model.
6
+ * Uses the query template: "task: search result | query: {query}"
7
+ */
8
+ export async function embedQuery(query: string): Promise<Float32Array> {
9
+ const pipe = getEmbeddingPipeline();
10
+ if (!pipe) throw new Error("Embedding model not loaded");
11
+ const text = EMBED_QUERY_TEMPLATE(query);
12
+ const output = await pipe(text, { pooling: "mean", normalize: true });
13
+ return new Float32Array(output.tolist()[0]);
14
+ }
15
+
16
+ /**
17
+ * Embed multiple texts in batch.
18
+ */
19
+ export async function embedBatch(
20
+ texts: string[],
21
+ ): Promise<Float32Array[]> {
22
+ const pipe = getEmbeddingPipeline();
23
+ if (!pipe) throw new Error("Embedding model not loaded");
24
+ const output = await pipe(texts, { pooling: "mean", normalize: true });
25
+ return output.tolist().map((arr: number[]) => new Float32Array(arr));
26
+ }
27
+
28
+ /**
29
+ * Embed a document chunk using the doc template.
30
+ * Uses the doc template: "title: {title} | text: {body}"
31
+ */
32
+ export async function embedDocChunk(
33
+ title: string,
34
+ chunkText: string,
35
+ ): Promise<Float32Array> {
36
+ const pipe = getEmbeddingPipeline();
37
+ if (!pipe) throw new Error("Embedding model not loaded");
38
+ const text = EMBED_DOC_TEMPLATE(title, chunkText);
39
+ const output = await pipe(text, { pooling: "mean", normalize: true });
40
+ return new Float32Array(output.tolist()[0]);
41
+ }
src/pipeline/vectorSearch.test.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { cosineSimilarity, vectorSearch } from "./vectorSearch";
3
+ import type { EmbeddedChunk } from "../types";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helper to create an EmbeddedChunk with a given embedding
7
+ // ---------------------------------------------------------------------------
8
+ function makeEmbeddedChunk(
9
+ embedding: number[],
10
+ docId = "doc1",
11
+ chunkIndex = 0,
12
+ ): EmbeddedChunk {
13
+ return {
14
+ docId,
15
+ chunkIndex,
16
+ text: `chunk ${chunkIndex} of ${docId}`,
17
+ startChar: 0,
18
+ title: "Test",
19
+ embedding: new Float32Array(embedding),
20
+ };
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // cosineSimilarity
25
+ // ---------------------------------------------------------------------------
26
+ describe("cosineSimilarity", () => {
27
+ it("returns 1 for identical vectors", () => {
28
+ const v = new Float32Array([1, 2, 3]);
29
+ expect(cosineSimilarity(v, v)).toBeCloseTo(1.0, 5);
30
+ });
31
+
32
+ it("returns -1 for opposite vectors", () => {
33
+ const a = new Float32Array([1, 0, 0]);
34
+ const b = new Float32Array([-1, 0, 0]);
35
+ expect(cosineSimilarity(a, b)).toBeCloseTo(-1.0, 5);
36
+ });
37
+
38
+ it("returns 0 for orthogonal vectors", () => {
39
+ const a = new Float32Array([1, 0, 0]);
40
+ const b = new Float32Array([0, 1, 0]);
41
+ expect(cosineSimilarity(a, b)).toBeCloseTo(0.0, 5);
42
+ });
43
+
44
+ it("is symmetric", () => {
45
+ const a = new Float32Array([1, 2, 3]);
46
+ const b = new Float32Array([4, 5, 6]);
47
+ expect(cosineSimilarity(a, b)).toBeCloseTo(cosineSimilarity(b, a), 10);
48
+ });
49
+
50
+ it("is scale-invariant", () => {
51
+ const a = new Float32Array([1, 2, 3]);
52
+ const b = new Float32Array([2, 4, 6]); // 2x scale of a
53
+ expect(cosineSimilarity(a, b)).toBeCloseTo(1.0, 5);
54
+ });
55
+
56
+ it("computes correct value for known vectors", () => {
57
+ // a = [3, 4], b = [4, 3]
58
+ // dot = 12 + 12 = 24, |a| = 5, |b| = 5 → cos = 24/25 = 0.96
59
+ const a = new Float32Array([3, 4]);
60
+ const b = new Float32Array([4, 3]);
61
+ expect(cosineSimilarity(a, b)).toBeCloseTo(0.96, 2);
62
+ });
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // vectorSearch
67
+ // ---------------------------------------------------------------------------
68
+ describe("vectorSearch", () => {
69
+ // Create chunks with embeddings at known angles from the query
70
+ const query = new Float32Array([1, 0, 0]);
71
+
72
+ const chunks: EmbeddedChunk[] = [
73
+ makeEmbeddedChunk([0, 1, 0], "orthogonal", 0), // cos = 0
74
+ makeEmbeddedChunk([1, 0, 0], "identical", 0), // cos = 1
75
+ makeEmbeddedChunk([0.7, 0.7, 0], "similar", 0), // cos ≈ 0.707
76
+ makeEmbeddedChunk([-1, 0, 0], "opposite", 0), // cos = -1
77
+ makeEmbeddedChunk([0.9, 0.1, 0], "very-similar", 0), // cos ≈ 0.994
78
+ ];
79
+
80
+ it("returns results sorted by descending score", () => {
81
+ const results = vectorSearch(query, chunks);
82
+ for (let i = 1; i < results.length; i++) {
83
+ expect(results[i].score).toBeLessThanOrEqual(results[i - 1].score);
84
+ }
85
+ });
86
+
87
+ it("ranks identical vector highest", () => {
88
+ const results = vectorSearch(query, chunks);
89
+ expect(results[0].chunk.docId).toBe("identical");
90
+ });
91
+
92
+ it("ranks opposite vector lowest", () => {
93
+ const results = vectorSearch(query, chunks);
94
+ expect(results[results.length - 1].chunk.docId).toBe("opposite");
95
+ });
96
+
97
+ it("all results have source 'vector'", () => {
98
+ const results = vectorSearch(query, chunks);
99
+ for (const r of results) {
100
+ expect(r.source).toBe("vector");
101
+ }
102
+ });
103
+
104
+ it("respects topK parameter", () => {
105
+ const results = vectorSearch(query, chunks, 2);
106
+ expect(results.length).toBe(2);
107
+ expect(results[0].chunk.docId).toBe("identical");
108
+ expect(results[1].chunk.docId).toBe("very-similar");
109
+ });
110
+
111
+ it("returns all when topK exceeds chunk count", () => {
112
+ const results = vectorSearch(query, chunks, 100);
113
+ expect(results.length).toBe(chunks.length);
114
+ });
115
+
116
+ it("handles empty chunks array", () => {
117
+ const results = vectorSearch(query, []);
118
+ expect(results).toEqual([]);
119
+ });
120
+
121
+ it("handles single chunk", () => {
122
+ const single = [makeEmbeddedChunk([0.5, 0.5, 0], "only", 0)];
123
+ const results = vectorSearch(query, single);
124
+ expect(results.length).toBe(1);
125
+ expect(results[0].source).toBe("vector");
126
+ expect(results[0].score).toBeGreaterThan(0);
127
+ });
128
+ });
src/pipeline/vectorSearch.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { EmbeddedChunk, ScoredChunk } from "../types";
2
+
3
+ /**
4
+ * Cosine similarity between two vectors.
5
+ */
6
+ export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
7
+ let dot = 0;
8
+ let normA = 0;
9
+ let normB = 0;
10
+ for (let i = 0; i < a.length; i++) {
11
+ dot += a[i] * b[i];
12
+ normA += a[i] * a[i];
13
+ normB += b[i] * b[i];
14
+ }
15
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
16
+ }
17
+
18
+ /**
19
+ * Search embedded chunks by cosine similarity to query embedding.
20
+ * Returns the top-K results sorted by descending similarity score.
21
+ */
22
+ export function vectorSearch(
23
+ queryEmbedding: Float32Array,
24
+ chunks: EmbeddedChunk[],
25
+ topK: number = 20,
26
+ ): ScoredChunk[] {
27
+ const scored = chunks.map((chunk) => ({
28
+ chunk,
29
+ score: cosineSimilarity(queryEmbedding, chunk.embedding),
30
+ source: "vector" as const,
31
+ }));
32
+ scored.sort((a, b) => b.score - a.score);
33
+ return scored.slice(0, topK);
34
+ }