Skip to content

Commit 8ca4351

Browse files
committed
feat: add Voyage AI embedding search with pure-JS cosine similarity
- New src/embedding.ts: Voyage AI client, cosine similarity, vectorSearch - embed() calls POST https://api.voyageai.com/v1/embeddings - cosineSimilarity() pure-JS dot product / magnitude - vectorSearch() brute-force over knowledge BLOBs (<100 entries) - embedKnowledgeEntry() fire-and-forget (errors logged, never thrown) - backfillEmbeddings() batch-embeds entries missing embeddings - checkConfigChange() detects model/dimension changes and clears stale embeddings for re-embedding on next backfill - Schema migration v8: - ADD COLUMN embedding BLOB to knowledge table - CREATE TABLE kv_meta (key-value store for plugin state) - Config: search.embeddings section (enabled, model, dimensions) - Default: disabled, voyage-code-3, 1024 dims - Requires VOYAGE_API_KEY env var - Hook embedding into ltm.create() and ltm.update() - Fire-and-forget after sync DB write - Re-embeds on content change - Add vector search as additional RRF list in recall tool - Same k: key prefix as BM25 knowledge — RRF merges, not duplicates - Entries found by both BM25 and vector get boosted score - Startup backfill when embeddings first enabled - Migration strategy: on startup, compare model+dimensions config fingerprint against stored value — if changed, clear all embeddings and re-embed in background - 18 new tests: cosine similarity, BLOB round-trip, vectorSearch, isAvailable, config schema, config change detection
1 parent dd7a435 commit 8ca4351

9 files changed

Lines changed: 659 additions & 3 deletions

File tree

src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,27 @@ export const LoreConfig = z.object({
6666
* When enabled, the configured model generates 2–3 alternative query phrasings
6767
* before search, improving recall for ambiguous queries. */
6868
queryExpansion: z.boolean().default(false),
69+
/** Vector embedding search via Voyage AI. Requires VOYAGE_API_KEY env var. */
70+
embeddings: z
71+
.object({
72+
/** Enable vector embedding search. Requires VOYAGE_API_KEY env var. Default: false. */
73+
enabled: z.boolean().default(false),
74+
/** Voyage AI model ID. Default: voyage-code-3. */
75+
model: z.string().default("voyage-code-3"),
76+
/** Embedding dimensions. Default: 1024. */
77+
dimensions: z.number().min(256).max(2048).default(1024),
78+
})
79+
.default({
80+
enabled: false,
81+
model: "voyage-code-3",
82+
dimensions: 1024,
83+
}),
6984
})
7085
.default({
7186
ftsWeights: { title: 6.0, content: 2.0, category: 3.0 },
7287
recallLimit: 10,
7388
queryExpansion: false,
89+
embeddings: { enabled: false, model: "voyage-code-3", dimensions: 1024 },
7490
}),
7591
crossProject: z.boolean().default(false),
7692
agentsFile: z

src/db.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
22
import { join, dirname } from "path";
33
import { mkdirSync } from "fs";
44

5-
const SCHEMA_VERSION = 7;
5+
const SCHEMA_VERSION = 8;
66

77
const MIGRATIONS: string[] = [
88
`
@@ -208,6 +208,18 @@ const MIGRATIONS: string[] = [
208208
INSERT INTO distillation_fts(rowid, observations) VALUES (new.rowid, new.observations);
209209
END;
210210
`,
211+
`
212+
-- Version 8: Embedding BLOB column for vector search (Voyage AI).
213+
-- No backfill — entries get embedded lazily on next create/update
214+
-- or via explicit backfill when embeddings are first enabled.
215+
ALTER TABLE knowledge ADD COLUMN embedding BLOB;
216+
217+
-- Key-value metadata table for plugin state (e.g. embedding config fingerprint).
218+
CREATE TABLE IF NOT EXISTS kv_meta (
219+
key TEXT PRIMARY KEY,
220+
value TEXT NOT NULL
221+
);
222+
`,
211223
];
212224

213225
function dataDir() {

src/embedding.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* Voyage AI embedding integration for vector search.
3+
*
4+
* Provides embedding generation via Voyage AI's REST API, pure-JS cosine
5+
* similarity, and vector search over the knowledge table. All operations
6+
* are gated behind `search.embeddings.enabled` config + `VOYAGE_API_KEY`
7+
* env var — falls back silently to FTS-only when unavailable.
8+
*/
9+
10+
import { db } from "./db";
11+
import { config } from "./config";
12+
import * as log from "./log";
13+
14+
const VOYAGE_API_URL = "https://api.voyageai.com/v1/embeddings";
15+
16+
// ---------------------------------------------------------------------------
17+
// Availability
18+
// ---------------------------------------------------------------------------
19+
20+
function getApiKey(): string | undefined {
21+
return process.env.VOYAGE_API_KEY;
22+
}
23+
24+
/** Returns true if embedding is configured and the API key is present. */
25+
export function isAvailable(): boolean {
26+
return config().search.embeddings.enabled && !!getApiKey();
27+
}
28+
29+
// ---------------------------------------------------------------------------
30+
// Voyage AI API
31+
// ---------------------------------------------------------------------------
32+
33+
type VoyageResponse = {
34+
data: Array<{ embedding: number[]; index: number }>;
35+
model: string;
36+
usage: { total_tokens: number };
37+
};
38+
39+
/**
40+
* Call Voyage AI embeddings API.
41+
*
42+
* @param texts Array of texts to embed (max 128 per call)
43+
* @param inputType "document" for storage, "query" for search
44+
* @returns Float32Array per input text
45+
* @throws On API errors or missing API key
46+
*/
47+
export async function embed(
48+
texts: string[],
49+
inputType: "document" | "query",
50+
): Promise<Float32Array[]> {
51+
const apiKey = getApiKey();
52+
if (!apiKey) throw new Error("VOYAGE_API_KEY not set");
53+
54+
const cfg = config().search.embeddings;
55+
56+
const res = await fetch(VOYAGE_API_URL, {
57+
method: "POST",
58+
headers: {
59+
"Content-Type": "application/json",
60+
Authorization: `Bearer ${apiKey}`,
61+
},
62+
body: JSON.stringify({
63+
input: texts,
64+
model: cfg.model,
65+
input_type: inputType,
66+
output_dimension: cfg.dimensions,
67+
}),
68+
});
69+
70+
if (!res.ok) {
71+
const body = await res.text().catch(() => "");
72+
throw new Error(`Voyage API ${res.status}: ${body}`);
73+
}
74+
75+
const json = (await res.json()) as VoyageResponse;
76+
// Sort by index to match input order (API may reorder)
77+
const sorted = [...json.data].sort((a, b) => a.index - b.index);
78+
return sorted.map((d) => new Float32Array(d.embedding));
79+
}
80+
81+
// ---------------------------------------------------------------------------
82+
// Cosine similarity (pure JS)
83+
// ---------------------------------------------------------------------------
84+
85+
/**
86+
* Cosine similarity between two Float32Array vectors.
87+
* Returns -1.0 to 1.0 where 1.0 = identical direction.
88+
* Returns 0 if either vector is zero-length.
89+
*/
90+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
91+
const len = Math.min(a.length, b.length);
92+
let dot = 0;
93+
let normA = 0;
94+
let normB = 0;
95+
for (let i = 0; i < len; i++) {
96+
dot += a[i] * b[i];
97+
normA += a[i] * a[i];
98+
normB += b[i] * b[i];
99+
}
100+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
101+
if (denom === 0) return 0;
102+
return dot / denom;
103+
}
104+
105+
// ---------------------------------------------------------------------------
106+
// BLOB conversion
107+
// ---------------------------------------------------------------------------
108+
109+
/** Convert Float32Array to Buffer for SQLite BLOB storage. */
110+
export function toBlob(arr: Float32Array): Buffer {
111+
return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength);
112+
}
113+
114+
/** Convert SQLite BLOB (Buffer/Uint8Array) back to Float32Array. */
115+
export function fromBlob(blob: Buffer | Uint8Array): Float32Array {
116+
const bytes = new Uint8Array(blob);
117+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
118+
}
119+
120+
// ---------------------------------------------------------------------------
121+
// Vector search
122+
// ---------------------------------------------------------------------------
123+
124+
type VectorHit = { id: string; similarity: number };
125+
126+
/**
127+
* Search all knowledge entries with embeddings by cosine similarity.
128+
* Returns top-k entries sorted by similarity descending.
129+
* Pure brute-force — fine for <100 entries (microseconds).
130+
*/
131+
export function vectorSearch(
132+
queryEmbedding: Float32Array,
133+
limit = 10,
134+
): VectorHit[] {
135+
const rows = db()
136+
.query("SELECT id, embedding FROM knowledge WHERE embedding IS NOT NULL AND confidence > 0.2")
137+
.all() as Array<{ id: string; embedding: Buffer }>;
138+
139+
const scored: VectorHit[] = [];
140+
for (const row of rows) {
141+
const vec = fromBlob(row.embedding);
142+
const sim = cosineSimilarity(queryEmbedding, vec);
143+
scored.push({ id: row.id, similarity: sim });
144+
}
145+
146+
scored.sort((a, b) => b.similarity - a.similarity);
147+
return scored.slice(0, limit);
148+
}
149+
150+
// ---------------------------------------------------------------------------
151+
// Fire-and-forget embedding
152+
// ---------------------------------------------------------------------------
153+
154+
/**
155+
* Embed a knowledge entry and store the result in the DB.
156+
* Fire-and-forget — errors are logged, never thrown.
157+
* The entry remains usable via FTS even if embedding fails.
158+
*/
159+
export function embedKnowledgeEntry(
160+
id: string,
161+
title: string,
162+
content: string,
163+
): void {
164+
const text = `${title}\n${content}`;
165+
embed([text], "document")
166+
.then(([vec]) => {
167+
db()
168+
.query("UPDATE knowledge SET embedding = ? WHERE id = ?")
169+
.run(toBlob(vec), id);
170+
})
171+
.catch((err) => {
172+
log.info("embedding failed for entry", id, ":", err);
173+
});
174+
}
175+
176+
// ---------------------------------------------------------------------------
177+
// Config change detection
178+
// ---------------------------------------------------------------------------
179+
180+
/**
181+
* Build a config fingerprint from model + dimensions.
182+
* Used to detect when the embedding config changes (model swap, dimension change)
183+
* so we can clear stale embeddings and re-embed.
184+
*/
185+
function configFingerprint(): string {
186+
const cfg = config().search.embeddings;
187+
return `${cfg.model}:${cfg.dimensions}`;
188+
}
189+
190+
const EMBEDDING_CONFIG_KEY = "lore:embedding_config";
191+
192+
/**
193+
* Check if embedding config has changed since the last backfill.
194+
* If so, clear all existing embeddings (they're incompatible) and
195+
* update the stored fingerprint.
196+
*
197+
* Returns true if embeddings were cleared (full re-embed needed).
198+
*/
199+
export function checkConfigChange(): boolean {
200+
// Read stored fingerprint from schema_version metadata (reuse the table)
201+
const stored = db()
202+
.query("SELECT value FROM kv_meta WHERE key = ?")
203+
.get(EMBEDDING_CONFIG_KEY) as { value: string } | null;
204+
205+
const current = configFingerprint();
206+
207+
if (stored && stored.value === current) return false;
208+
209+
// Config changed (or first run) — clear all embeddings
210+
if (stored) {
211+
const count = db()
212+
.query("SELECT COUNT(*) as n FROM knowledge WHERE embedding IS NOT NULL")
213+
.get() as { n: number };
214+
if (count.n > 0) {
215+
db().query("UPDATE knowledge SET embedding = NULL").run();
216+
log.info(
217+
`embedding config changed (${stored.value}${current}), cleared ${count.n} stale embeddings`,
218+
);
219+
}
220+
}
221+
222+
// Store new fingerprint
223+
db()
224+
.query(
225+
"INSERT INTO kv_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
226+
)
227+
.run(EMBEDDING_CONFIG_KEY, current, current);
228+
229+
return true;
230+
}
231+
232+
// ---------------------------------------------------------------------------
233+
// Backfill
234+
// ---------------------------------------------------------------------------
235+
236+
/**
237+
* Embed all knowledge entries that are missing embeddings.
238+
* Called on startup when embeddings are first enabled.
239+
* Also handles config changes: if model/dimensions changed, clears
240+
* stale embeddings first, then re-embeds all entries.
241+
* Returns the number of entries embedded.
242+
*/
243+
export async function backfillEmbeddings(): Promise<number> {
244+
// Detect model/dimension changes and clear stale embeddings
245+
checkConfigChange();
246+
247+
const rows = db()
248+
.query("SELECT id, title, content FROM knowledge WHERE embedding IS NULL AND confidence > 0.2")
249+
.all() as Array<{ id: string; title: string; content: string }>;
250+
251+
if (!rows.length) return 0;
252+
253+
// Batch embed (Voyage supports up to 128 per call)
254+
const BATCH_SIZE = 128;
255+
let embedded = 0;
256+
257+
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
258+
const batch = rows.slice(i, i + BATCH_SIZE);
259+
const texts = batch.map((r) => `${r.title}\n${r.content}`);
260+
261+
try {
262+
const vectors = await embed(texts, "document");
263+
const update = db().prepare(
264+
"UPDATE knowledge SET embedding = ? WHERE id = ?",
265+
);
266+
267+
for (let j = 0; j < batch.length; j++) {
268+
update.run(toBlob(vectors[j]), batch[j].id);
269+
embedded++;
270+
}
271+
} catch (err) {
272+
log.info(`embedding backfill batch ${i}-${i + batch.length} failed:`, err);
273+
}
274+
}
275+
276+
if (embedded > 0) {
277+
log.info(`embedded ${embedded} knowledge entries`);
278+
}
279+
return embedded;
280+
}

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { formatKnowledge, formatDistillations } from "./prompt";
2020
import { createRecallTool } from "./reflect";
2121
import { shouldImport, importFromFile, exportToFile } from "./agents-file";
22+
import * as embedding from "./embedding";
2223
import * as log from "./log";
2324

2425
/**
@@ -678,6 +679,15 @@ End with "I'm ready to continue." so the agent knows to pick up where it left of
678679
// appears for a project, the init failed (see catch block below).
679680
process.stderr.write(`[lore] active: ${projectPath}\n`);
680681

682+
// Background: backfill embeddings for entries that don't have one yet.
683+
// Fires once when embeddings are first enabled — subsequent entries
684+
// get embedded on create/update via ltm.ts hooks.
685+
if (config().search.embeddings.enabled && embedding.isAvailable()) {
686+
embedding.backfillEmbeddings().catch((err) => {
687+
log.info("embedding backfill failed:", err);
688+
});
689+
}
690+
681691
return hooks;
682692
} catch (e) {
683693
// Log the full error before re-throwing so OpenCode's plugin loader

src/ltm.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { uuidv7 } from "uuidv7";
22
import { db, ensureProject } from "./db";
33
import { config } from "./config";
44
import { ftsQuery, ftsQueryOr, EMPTY_QUERY, extractTopTerms } from "./search";
5+
import * as embedding from "./embedding";
56

67
// ~3 chars per token — validated as best heuristic against real API data.
78
function estimateTokens(text: string): number {
@@ -98,6 +99,12 @@ export function create(input: {
9899
now,
99100
now,
100101
);
102+
103+
// Fire-and-forget: embed for vector search (errors logged, never thrown)
104+
if (embedding.isAvailable()) {
105+
embedding.embedKnowledgeEntry(id, input.title, input.content);
106+
}
107+
101108
return id;
102109
}
103110

@@ -121,6 +128,14 @@ export function update(
121128
db()
122129
.query(`UPDATE knowledge SET ${sets.join(", ")} WHERE id = ?`)
123130
.run(...(params as [string, ...string[]]));
131+
132+
// Re-embed when content changes (fire-and-forget)
133+
if (embedding.isAvailable() && input.content !== undefined) {
134+
const entry = get(id);
135+
if (entry) {
136+
embedding.embedKnowledgeEntry(id, entry.title, input.content);
137+
}
138+
}
124139
}
125140

126141
export function remove(id: string) {

0 commit comments

Comments
 (0)