libsql-search 0.1.2 → 0.1.4

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