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