add embeddings and vector search modules
Browse filesEmbeddings 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 +41 -0
- src/pipeline/vectorSearch.test.ts +128 -0
- src/pipeline/vectorSearch.ts +34 -0
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 |
+
}
|