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 +7 -1
- package/dist/index.cjs +354 -0
- package/dist/index.d.ts +0 -4
- package/dist/index.esm.js +249 -295
- package/package.json +29 -18
- package/dist/index.js +0 -400
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# libsql-search
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/libsql-search)
|
|
4
|
+
[](https://jsr.io/@logan/libsql-search)
|
|
5
|
+
[](https://www.npmjs.com/package/libsql-search)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](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 @
|
|
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