libsql-search 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ import { pipeline } from '@xenova/transformers';
2
+ import { readdir, readFile } from 'fs/promises';
3
+ import { join, extname, relative, dirname } from 'path';
4
+ import matter from 'gray-matter';
5
+
6
+ /**
7
+ * Multi-provider embedding generation
8
+ * Supports local (Xenova), Gemini, and OpenAI
9
+ */
10
+ const providerCache = {};
11
+ /**
12
+ * Generate embeddings using the specified provider
13
+ */
14
+ async function generateEmbedding(text, options = {}) {
15
+ const { provider = 'local', apiKey, dimensions = 768, maxLength = 8000 } = options;
16
+ const truncated = text.substring(0, maxLength);
17
+ switch (provider) {
18
+ case 'local':
19
+ return generateLocalEmbedding(truncated, dimensions);
20
+ case 'gemini':
21
+ return generateGeminiEmbedding(truncated, apiKey);
22
+ case 'openai':
23
+ return generateOpenAIEmbedding(truncated, apiKey, dimensions);
24
+ default:
25
+ throw new Error(`Unknown embedding provider: ${provider}`);
26
+ }
27
+ }
28
+ /**
29
+ * Generate embeddings using local model (Xenova/all-MiniLM-L6-v2)
30
+ * Returns 384 dimensions, padded to target dimensions
31
+ */
32
+ async function generateLocalEmbedding(text, targetDimensions) {
33
+ if (!providerCache.local) {
34
+ console.log('Loading local embedding model (Xenova/all-MiniLM-L6-v2)...');
35
+ providerCache.local = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
36
+ console.log('Local model loaded successfully');
37
+ }
38
+ const output = await providerCache.local(text, {
39
+ pooling: 'mean',
40
+ normalize: true
41
+ });
42
+ const embedding = Array.from(output.data);
43
+ return padEmbedding(embedding, targetDimensions);
44
+ }
45
+ /**
46
+ * Generate embeddings using Google Gemini API
47
+ * Returns 768 dimensions natively
48
+ */
49
+ async function generateGeminiEmbedding(text, apiKey) {
50
+ const key = apiKey || process.env.GEMINI_API_KEY;
51
+ if (!key) {
52
+ throw new Error('GEMINI_API_KEY is required for Gemini embeddings');
53
+ }
54
+ if (!providerCache.gemini) {
55
+ // Dynamic import to keep it optional
56
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
57
+ const genAI = new GoogleGenerativeAI(key);
58
+ providerCache.gemini = genAI.getGenerativeModel({ model: 'text-embedding-004' });
59
+ }
60
+ const result = await providerCache.gemini.embedContent(text);
61
+ return result.embedding.values;
62
+ }
63
+ /**
64
+ * Generate embeddings using OpenAI API
65
+ * Supports text-embedding-3-small (1536 dims) and text-embedding-3-large (3072 dims)
66
+ */
67
+ async function generateOpenAIEmbedding(text, apiKey, dimensions = 1536) {
68
+ const key = apiKey || process.env.OPENAI_API_KEY;
69
+ if (!key) {
70
+ throw new Error('OPENAI_API_KEY is required for OpenAI embeddings');
71
+ }
72
+ const model = dimensions <= 1536 ? 'text-embedding-3-small' : 'text-embedding-3-large';
73
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'Authorization': `Bearer ${key}`
78
+ },
79
+ body: JSON.stringify({
80
+ input: text,
81
+ model,
82
+ dimensions
83
+ })
84
+ });
85
+ if (!response.ok) {
86
+ const error = await response.text();
87
+ throw new Error(`OpenAI API error: ${error}`);
88
+ }
89
+ const data = await response.json();
90
+ return data.data[0].embedding;
91
+ }
92
+ /**
93
+ * Pad or truncate embedding to target dimensions
94
+ */
95
+ function padEmbedding(embedding, targetDimensions) {
96
+ if (embedding.length === targetDimensions) {
97
+ return embedding;
98
+ }
99
+ if (embedding.length > targetDimensions) {
100
+ return embedding.slice(0, targetDimensions);
101
+ }
102
+ const padded = new Array(targetDimensions).fill(0);
103
+ padded.splice(0, embedding.length, ...embedding);
104
+ return padded;
105
+ }
106
+ /**
107
+ * Prepare text for embedding by combining multiple fields
108
+ */
109
+ function prepareTextForEmbedding(fields) {
110
+ const parts = [];
111
+ if (fields.title)
112
+ parts.push(fields.title);
113
+ if (fields.description)
114
+ parts.push(fields.description);
115
+ if (fields.tags && fields.tags.length > 0) {
116
+ parts.push(`Tags: ${fields.tags.join(', ')}`);
117
+ }
118
+ if (fields.content)
119
+ parts.push(fields.content);
120
+ return parts.filter(Boolean).join('\n\n');
121
+ }
122
+
123
+ /**
124
+ * Content indexer for markdown and other formats
125
+ */
126
+ /**
127
+ * Index markdown content from a directory
128
+ */
129
+ async function indexContent(options) {
130
+ const { client, contentPath, embeddingOptions = {}, fileExtensions = ['.md', '.markdown'], exclude = ['node_modules', '.git', 'dist', 'build'], tableName = 'articles', onProgress } = options;
131
+ // Find all content files
132
+ const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
133
+ if (files.length === 0) {
134
+ console.warn(`No files found in ${contentPath}`);
135
+ return { success: 0, failed: 0, total: 0 };
136
+ }
137
+ // Clear existing content
138
+ await client.execute(`DELETE FROM ${tableName}`);
139
+ // Process each file
140
+ let success = 0;
141
+ let failed = 0;
142
+ for (let i = 0; i < files.length; i++) {
143
+ const file = files[i];
144
+ if (onProgress) {
145
+ onProgress(i + 1, files.length, file.relativePath);
146
+ }
147
+ try {
148
+ const document = await processFile(file, embeddingOptions);
149
+ await insertDocument(client, document, tableName);
150
+ success++;
151
+ }
152
+ catch (error) {
153
+ console.error(`Failed to index ${file.relativePath}:`, error);
154
+ failed++;
155
+ }
156
+ }
157
+ return { success, failed, total: files.length };
158
+ }
159
+ /**
160
+ * Find all files matching extensions
161
+ */
162
+ async function findFiles(dir, baseDir, extensions, exclude) {
163
+ const files = [];
164
+ const entries = await readdir(dir, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ const fullPath = join(dir, entry.name);
167
+ if (entry.isDirectory()) {
168
+ if (!entry.name.startsWith('.') && !exclude.includes(entry.name)) {
169
+ const subFiles = await findFiles(fullPath, baseDir, extensions, exclude);
170
+ files.push(...subFiles);
171
+ }
172
+ }
173
+ else if (extensions.includes(extname(entry.name))) {
174
+ const relativePath = relative(baseDir, fullPath);
175
+ const folder = dirname(relativePath);
176
+ files.push({
177
+ fullPath,
178
+ relativePath,
179
+ folder: folder === '.' ? 'root' : folder
180
+ });
181
+ }
182
+ }
183
+ return files;
184
+ }
185
+ /**
186
+ * Process a single file into an indexed document
187
+ */
188
+ async function processFile(file, embeddingOptions) {
189
+ const content = await readFile(file.fullPath, 'utf-8');
190
+ const { data: frontMatter, content: markdown } = matter(content);
191
+ // Generate slug from relative path
192
+ const slug = file.relativePath
193
+ .replace(/\.(md|markdown)$/, '')
194
+ .replace(/\\/g, '/');
195
+ // Extract metadata
196
+ const title = frontMatter.title || file.relativePath
197
+ .split('/').pop()
198
+ ?.replace(/\.(md|markdown)$/, '')
199
+ .replace(/-/g, ' ') || 'Untitled';
200
+ const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
201
+ // Generate embedding
202
+ const embeddingText = prepareTextForEmbedding({
203
+ title,
204
+ description: frontMatter.description,
205
+ content: markdown,
206
+ tags
207
+ });
208
+ const embedding = await generateEmbedding(embeddingText, embeddingOptions);
209
+ return {
210
+ slug,
211
+ title,
212
+ content: markdown,
213
+ folder: file.folder,
214
+ tags,
215
+ embedding,
216
+ metadata: frontMatter
217
+ };
218
+ }
219
+ /**
220
+ * Insert document into database
221
+ */
222
+ async function insertDocument(client, document, tableName) {
223
+ await client.execute({
224
+ sql: `INSERT INTO ${tableName}
225
+ (slug, title, content, folder, tags, embedding, created_at, updated_at)
226
+ VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
227
+ args: [
228
+ document.slug,
229
+ document.title,
230
+ document.content,
231
+ document.folder,
232
+ JSON.stringify(document.tags),
233
+ JSON.stringify(document.embedding)
234
+ ]
235
+ });
236
+ }
237
+ /**
238
+ * Create the articles table if it doesn't exist
239
+ */
240
+ async function createTable(client, tableName = 'articles', dimensions = 768) {
241
+ await client.execute(`
242
+ CREATE TABLE IF NOT EXISTS ${tableName} (
243
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
244
+ slug TEXT UNIQUE NOT NULL,
245
+ title TEXT NOT NULL,
246
+ content TEXT NOT NULL,
247
+ folder TEXT NOT NULL DEFAULT 'root',
248
+ tags TEXT DEFAULT '[]',
249
+ embedding F32_BLOB(${dimensions}),
250
+ created_at TEXT NOT NULL,
251
+ updated_at TEXT NOT NULL
252
+ )
253
+ `);
254
+ await client.execute(`
255
+ CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx
256
+ ON ${tableName}(libsql_vector_idx(embedding))
257
+ `);
258
+ await client.execute(`
259
+ CREATE INDEX IF NOT EXISTS ${tableName}_folder_idx
260
+ ON ${tableName}(folder)
261
+ `);
262
+ await client.execute(`
263
+ CREATE INDEX IF NOT EXISTS ${tableName}_slug_idx
264
+ ON ${tableName}(slug)
265
+ `);
266
+ }
267
+
268
+ /**
269
+ * Vector search functionality
270
+ */
271
+ /**
272
+ * Perform semantic search using vector similarity
273
+ */
274
+ async function search(options) {
275
+ const { client, query, limit = 10, tableName = 'articles', embeddingOptions = {} } = options;
276
+ // Generate embedding for query
277
+ const queryEmbedding = await generateEmbedding(query, embeddingOptions);
278
+ // Perform vector search
279
+ const results = await client.execute({
280
+ sql: `
281
+ SELECT
282
+ id,
283
+ slug,
284
+ title,
285
+ content,
286
+ folder,
287
+ tags,
288
+ created_at,
289
+ vector_distance_cos(embedding, vector(?)) as distance
290
+ FROM ${tableName}
291
+ WHERE embedding IS NOT NULL
292
+ ORDER BY distance
293
+ LIMIT ?
294
+ `,
295
+ args: [JSON.stringify(queryEmbedding), limit]
296
+ });
297
+ // Parse and format results
298
+ return results.rows.map(row => ({
299
+ id: row.id,
300
+ slug: row.slug,
301
+ title: row.title,
302
+ content: row.content,
303
+ folder: row.folder,
304
+ tags: JSON.parse(row.tags || '[]'),
305
+ distance: row.distance,
306
+ created_at: row.created_at
307
+ }));
308
+ }
309
+ /**
310
+ * Get all articles (for building static pages, navigation, etc.)
311
+ */
312
+ async function getAllArticles(client, tableName = 'articles') {
313
+ const results = await client.execute(`
314
+ SELECT id, slug, title, folder, tags, created_at, updated_at
315
+ FROM ${tableName}
316
+ ORDER BY title
317
+ `);
318
+ return results.rows.map(row => ({
319
+ id: row.id,
320
+ slug: row.slug,
321
+ title: row.title,
322
+ folder: row.folder,
323
+ tags: JSON.parse(row.tags || '[]'),
324
+ created_at: row.created_at,
325
+ updated_at: row.updated_at
326
+ }));
327
+ }
328
+ /**
329
+ * Get a single article by slug
330
+ */
331
+ async function getArticleBySlug(client, slug, tableName = 'articles') {
332
+ const results = await client.execute({
333
+ sql: `
334
+ SELECT id, slug, title, content, folder, tags, created_at, updated_at
335
+ FROM ${tableName}
336
+ WHERE slug = ?
337
+ LIMIT 1
338
+ `,
339
+ args: [slug]
340
+ });
341
+ if (results.rows.length === 0) {
342
+ return null;
343
+ }
344
+ const row = results.rows[0];
345
+ return {
346
+ id: row.id,
347
+ slug: row.slug,
348
+ title: row.title,
349
+ content: row.content,
350
+ folder: row.folder,
351
+ tags: JSON.parse(row.tags || '[]'),
352
+ created_at: row.created_at,
353
+ updated_at: row.updated_at
354
+ };
355
+ }
356
+ /**
357
+ * Get articles by folder
358
+ */
359
+ async function getArticlesByFolder(client, folder, tableName = 'articles') {
360
+ const results = await client.execute({
361
+ sql: `
362
+ SELECT id, slug, title, folder, tags
363
+ FROM ${tableName}
364
+ WHERE folder = ?
365
+ ORDER BY title
366
+ `,
367
+ args: [folder]
368
+ });
369
+ return results.rows.map(row => ({
370
+ id: row.id,
371
+ slug: row.slug,
372
+ title: row.title,
373
+ folder: row.folder,
374
+ tags: JSON.parse(row.tags || '[]')
375
+ }));
376
+ }
377
+ /**
378
+ * Get all unique folders
379
+ */
380
+ async function getFolders(client, tableName = 'articles') {
381
+ const results = await client.execute(`
382
+ SELECT DISTINCT folder
383
+ FROM ${tableName}
384
+ ORDER BY folder
385
+ `);
386
+ return results.rows.map(row => row.folder);
387
+ }
388
+
389
+ export { createTable, generateEmbedding, getAllArticles, getArticleBySlug, getArticlesByFolder, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search };