libsql-search 0.1.3 → 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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # libsql-search
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/libsql-search.svg)](https://www.npmjs.com/package/libsql-search)
4
+ [![JSR](https://jsr.io/badges/@logan/libsql-search)](https://jsr.io/@logan/libsql-search)
5
+ [![npm downloads](https://img.shields.io/npm/dm/libsql-search.svg)](https://www.npmjs.com/package/libsql-search)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![CI](https://github.com/llbbl/libsql-search/actions/workflows/ci.yml/badge.svg)](https://github.com/llbbl/libsql-search/actions/workflows/ci.yml)
8
+
3
9
  Semantic search for static sites using libSQL/Turso with multi-provider embeddings.
4
10
 
5
11
  Add AI-powered vector search to your Astro, Next.js, or any static site with minimal configuration. Index markdown content, generate embeddings locally or via API, and provide lightning-fast semantic search to your users.
@@ -27,7 +33,7 @@ pnpm add libsql-search @libsql/client
27
33
 
28
34
  **JSR:**
29
35
  ```bash
30
- deno add @llbbl/libsql-search
36
+ deno add @logan/libsql-search
31
37
  ```
32
38
 
33
39
  ## Quick Start
package/dist/index.cjs ADDED
@@ -0,0 +1,354 @@
1
+ 'use strict';
2
+
3
+ var promises = require('fs/promises');
4
+ var path = require('path');
5
+ var matter = require('gray-matter');
6
+
7
+ const providerCache = {};
8
+ function getEnvironmentVariable(name) {
9
+ const runtime = globalThis;
10
+ const nodeValue = runtime.process?.env?.[name];
11
+ if (nodeValue) {
12
+ return nodeValue;
13
+ }
14
+ try {
15
+ return runtime.Deno?.env?.get?.(name);
16
+ } catch {
17
+ return void 0;
18
+ }
19
+ }
20
+ async function getLocalEmbeddingModel() {
21
+ if (!providerCache.local) {
22
+ console.log("Loading local embedding model (Xenova/all-MiniLM-L6-v2)...");
23
+ const { pipeline } = await import('@xenova/transformers');
24
+ providerCache.local = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
25
+ console.log("Local model loaded successfully");
26
+ }
27
+ return providerCache.local;
28
+ }
29
+ async function generateEmbedding(text, options = {}) {
30
+ const {
31
+ provider = "local",
32
+ apiKey,
33
+ dimensions = 768,
34
+ maxLength = 8e3
35
+ } = options;
36
+ const truncated = text.substring(0, maxLength);
37
+ switch (provider) {
38
+ case "local":
39
+ return generateLocalEmbedding(truncated, dimensions);
40
+ case "gemini":
41
+ return generateGeminiEmbedding(truncated, apiKey);
42
+ case "openai":
43
+ return generateOpenAIEmbedding(truncated, apiKey, dimensions);
44
+ default:
45
+ throw new Error(`Unknown embedding provider: ${provider}`);
46
+ }
47
+ }
48
+ async function generateLocalEmbedding(text, targetDimensions) {
49
+ const model = await getLocalEmbeddingModel();
50
+ const output = await model(text, {
51
+ pooling: "mean",
52
+ normalize: true
53
+ });
54
+ const embedding = Array.from(output.data);
55
+ return padEmbedding(embedding, targetDimensions);
56
+ }
57
+ async function generateGeminiEmbedding(text, apiKey) {
58
+ const key = apiKey || getEnvironmentVariable("GEMINI_API_KEY");
59
+ if (!key) {
60
+ throw new Error("GEMINI_API_KEY is required for Gemini embeddings");
61
+ }
62
+ if (!providerCache.gemini) {
63
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
64
+ const genAI = new GoogleGenerativeAI(key);
65
+ providerCache.gemini = genAI.getGenerativeModel({ model: "text-embedding-004" });
66
+ }
67
+ const result = await providerCache.gemini.embedContent(text);
68
+ return result.embedding.values;
69
+ }
70
+ async function generateOpenAIEmbedding(text, apiKey, dimensions = 1536) {
71
+ const key = apiKey || getEnvironmentVariable("OPENAI_API_KEY");
72
+ if (!key) {
73
+ throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
74
+ }
75
+ const model = dimensions <= 1536 ? "text-embedding-3-small" : "text-embedding-3-large";
76
+ const response = await fetch("https://api.openai.com/v1/embeddings", {
77
+ method: "POST",
78
+ headers: {
79
+ "Content-Type": "application/json",
80
+ "Authorization": `Bearer ${key}`
81
+ },
82
+ body: JSON.stringify({
83
+ input: text,
84
+ model,
85
+ dimensions
86
+ })
87
+ });
88
+ if (!response.ok) {
89
+ const error = await response.text();
90
+ throw new Error(`OpenAI API error: ${error}`);
91
+ }
92
+ const data = await response.json();
93
+ return data.data[0].embedding;
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
+ function prepareTextForEmbedding(fields) {
107
+ const parts = [];
108
+ if (fields.title) parts.push(fields.title);
109
+ if (fields.description) parts.push(fields.description);
110
+ if (fields.tags && fields.tags.length > 0) {
111
+ parts.push(`Tags: ${fields.tags.join(", ")}`);
112
+ }
113
+ if (fields.content) parts.push(fields.content);
114
+ return parts.filter(Boolean).join("\n\n");
115
+ }
116
+
117
+ async function indexContent(options) {
118
+ const {
119
+ client,
120
+ contentPath,
121
+ embeddingOptions = {},
122
+ fileExtensions = [".md", ".markdown"],
123
+ exclude = ["node_modules", ".git", "dist", "build"],
124
+ tableName = "articles",
125
+ onProgress
126
+ } = options;
127
+ const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
128
+ if (files.length === 0) {
129
+ console.warn(`No files found in ${contentPath}`);
130
+ return { success: 0, failed: 0, total: 0 };
131
+ }
132
+ await client.execute(`DELETE FROM ${tableName}`);
133
+ let success = 0;
134
+ let failed = 0;
135
+ for (let i = 0; i < files.length; i++) {
136
+ const file = files[i];
137
+ if (onProgress) {
138
+ onProgress(i + 1, files.length, file.relativePath);
139
+ }
140
+ try {
141
+ const document = await processFile(file, embeddingOptions);
142
+ await insertDocument(client, document, tableName);
143
+ success++;
144
+ } catch (error) {
145
+ console.error(`Failed to index ${file.relativePath}:`, error);
146
+ failed++;
147
+ }
148
+ }
149
+ return { success, failed, total: files.length };
150
+ }
151
+ async function findFiles(dir, baseDir, extensions, exclude) {
152
+ const files = [];
153
+ const entries = await promises.readdir(dir, { withFileTypes: true });
154
+ for (const entry of entries) {
155
+ const fullPath = path.join(dir, entry.name);
156
+ if (entry.isDirectory()) {
157
+ if (!entry.name.startsWith(".") && !exclude.includes(entry.name)) {
158
+ const subFiles = await findFiles(fullPath, baseDir, extensions, exclude);
159
+ files.push(...subFiles);
160
+ }
161
+ } else if (extensions.includes(path.extname(entry.name))) {
162
+ const relativePath = path.relative(baseDir, fullPath);
163
+ const folder = path.dirname(relativePath);
164
+ files.push({
165
+ fullPath,
166
+ relativePath,
167
+ folder: folder === "." ? "root" : folder
168
+ });
169
+ }
170
+ }
171
+ return files;
172
+ }
173
+ async function processFile(file, embeddingOptions) {
174
+ const content = await promises.readFile(file.fullPath, "utf-8");
175
+ const { data: frontMatter, content: markdown } = matter(content);
176
+ const slug = file.relativePath.replace(/\.(md|markdown)$/, "").replace(/\\/g, "/");
177
+ const title = frontMatter.title || file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
178
+ const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
179
+ const embeddingText = prepareTextForEmbedding({
180
+ title,
181
+ description: frontMatter.description,
182
+ content: markdown,
183
+ tags
184
+ });
185
+ const embedding = await generateEmbedding(embeddingText, embeddingOptions);
186
+ return {
187
+ slug,
188
+ title,
189
+ content: markdown,
190
+ folder: file.folder,
191
+ tags,
192
+ embedding,
193
+ metadata: frontMatter
194
+ };
195
+ }
196
+ async function insertDocument(client, document, tableName) {
197
+ await client.execute({
198
+ sql: `INSERT INTO ${tableName}
199
+ (slug, title, content, folder, tags, embedding, created_at, updated_at)
200
+ VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
201
+ args: [
202
+ document.slug,
203
+ document.title,
204
+ document.content,
205
+ document.folder,
206
+ JSON.stringify(document.tags),
207
+ JSON.stringify(document.embedding)
208
+ ]
209
+ });
210
+ }
211
+ async function createTable(client, tableName = "articles", dimensions = 768) {
212
+ await client.execute(`
213
+ CREATE TABLE IF NOT EXISTS ${tableName} (
214
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
215
+ slug TEXT UNIQUE NOT NULL,
216
+ title TEXT NOT NULL,
217
+ content TEXT NOT NULL,
218
+ folder TEXT NOT NULL DEFAULT 'root',
219
+ tags TEXT DEFAULT '[]',
220
+ embedding F32_BLOB(${dimensions}),
221
+ created_at TEXT NOT NULL,
222
+ updated_at TEXT NOT NULL
223
+ )
224
+ `);
225
+ await client.execute(`
226
+ CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx
227
+ ON ${tableName}(libsql_vector_idx(embedding))
228
+ `);
229
+ await client.execute(`
230
+ CREATE INDEX IF NOT EXISTS ${tableName}_folder_idx
231
+ ON ${tableName}(folder)
232
+ `);
233
+ await client.execute(`
234
+ CREATE INDEX IF NOT EXISTS ${tableName}_slug_idx
235
+ ON ${tableName}(slug)
236
+ `);
237
+ }
238
+
239
+ async function search(options) {
240
+ const {
241
+ client,
242
+ query,
243
+ limit = 10,
244
+ tableName = "articles",
245
+ embeddingOptions = {}
246
+ } = options;
247
+ const queryEmbedding = await generateEmbedding(query, embeddingOptions);
248
+ const results = await client.execute({
249
+ sql: `
250
+ SELECT
251
+ id,
252
+ slug,
253
+ title,
254
+ content,
255
+ folder,
256
+ tags,
257
+ created_at,
258
+ vector_distance_cos(embedding, vector(?)) as distance
259
+ FROM ${tableName}
260
+ WHERE embedding IS NOT NULL
261
+ ORDER BY distance
262
+ LIMIT ?
263
+ `,
264
+ args: [JSON.stringify(queryEmbedding), limit]
265
+ });
266
+ return results.rows.map((row) => ({
267
+ id: row.id,
268
+ slug: row.slug,
269
+ title: row.title,
270
+ content: row.content,
271
+ folder: row.folder,
272
+ tags: JSON.parse(row.tags || "[]"),
273
+ distance: row.distance,
274
+ created_at: row.created_at
275
+ }));
276
+ }
277
+ async function getAllArticles(client, tableName = "articles") {
278
+ const results = await client.execute(`
279
+ SELECT id, slug, title, folder, tags, created_at, updated_at
280
+ FROM ${tableName}
281
+ ORDER BY title
282
+ `);
283
+ return results.rows.map((row) => ({
284
+ id: row.id,
285
+ slug: row.slug,
286
+ title: row.title,
287
+ folder: row.folder,
288
+ tags: JSON.parse(row.tags || "[]"),
289
+ created_at: row.created_at,
290
+ updated_at: row.updated_at
291
+ }));
292
+ }
293
+ async function getArticleBySlug(client, slug, tableName = "articles") {
294
+ const results = await client.execute({
295
+ sql: `
296
+ SELECT id, slug, title, content, folder, tags, created_at, updated_at
297
+ FROM ${tableName}
298
+ WHERE slug = ?
299
+ LIMIT 1
300
+ `,
301
+ args: [slug]
302
+ });
303
+ if (results.rows.length === 0) {
304
+ return null;
305
+ }
306
+ const row = results.rows[0];
307
+ return {
308
+ id: row.id,
309
+ slug: row.slug,
310
+ title: row.title,
311
+ content: row.content,
312
+ folder: row.folder,
313
+ tags: JSON.parse(row.tags || "[]"),
314
+ created_at: row.created_at,
315
+ updated_at: row.updated_at
316
+ };
317
+ }
318
+ async function getArticlesByFolder(client, folder, tableName = "articles") {
319
+ const results = await client.execute({
320
+ sql: `
321
+ SELECT id, slug, title, folder, tags
322
+ FROM ${tableName}
323
+ WHERE folder = ?
324
+ ORDER BY title
325
+ `,
326
+ args: [folder]
327
+ });
328
+ return results.rows.map((row) => ({
329
+ id: row.id,
330
+ slug: row.slug,
331
+ title: row.title,
332
+ folder: row.folder,
333
+ tags: JSON.parse(row.tags || "[]")
334
+ }));
335
+ }
336
+ async function getFolders(client, tableName = "articles") {
337
+ const results = await client.execute(`
338
+ SELECT DISTINCT folder
339
+ FROM ${tableName}
340
+ ORDER BY folder
341
+ `);
342
+ return results.rows.map((row) => row.folder);
343
+ }
344
+
345
+ exports.createTable = createTable;
346
+ exports.generateEmbedding = generateEmbedding;
347
+ exports.getAllArticles = getAllArticles;
348
+ exports.getArticleBySlug = getArticleBySlug;
349
+ exports.getArticlesByFolder = getArticlesByFolder;
350
+ exports.getFolders = getFolders;
351
+ exports.indexContent = indexContent;
352
+ exports.padEmbedding = padEmbedding;
353
+ exports.prepareTextForEmbedding = prepareTextForEmbedding;
354
+ exports.search = search;
package/dist/index.d.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { Client } from '@libsql/client';
2
2
 
3
- /**
4
- * Multi-provider embedding generation
5
- * Supports local (Xenova), Gemini, and OpenAI
6
- */
7
3
  type EmbeddingProvider = 'local' | 'gemini' | 'openai';
8
4
  interface EmbeddingOptions {
9
5
  provider?: EmbeddingProvider;