libsql-search 0.7.0 → 0.8.0

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.
@@ -1,7 +1,120 @@
1
1
  # Integration Examples
2
2
 
3
- These examples show the current exported API wired into typical server-side
4
- routes. They are intentionally small so you can adapt them to your app.
3
+ These examples show the current exported API wired into common server-side flows. Start with the shared provider flow, then adapt the framework snippets to your app.
4
+
5
+ ## Shared Provider Flow
6
+
7
+ Use one provider preset, one dimension count, and one table name per embedding space.
8
+
9
+ ```ts
10
+ import { createClient } from "@libsql/client";
11
+ import { createTable, indexContent, search } from "libsql-search";
12
+
13
+ const client = createClient({
14
+ url: process.env.TURSO_DB_URL!,
15
+ authToken: process.env.TURSO_AUTH_TOKEN!,
16
+ });
17
+
18
+ const providerConfig = {
19
+ tableName: "articles_openai_1536",
20
+ dimensions: 1536,
21
+ embeddingOptions: {
22
+ provider: "openai" as const,
23
+ apiKey: process.env.OPENAI_API_KEY,
24
+ dimensions: 1536,
25
+ },
26
+ };
27
+
28
+ await createTable(client, providerConfig.tableName, providerConfig.dimensions);
29
+
30
+ await indexContent({
31
+ client,
32
+ contentPath: "./content",
33
+ tableName: providerConfig.tableName,
34
+ embeddingOptions: {
35
+ ...providerConfig.embeddingOptions,
36
+ intent: "document",
37
+ },
38
+ });
39
+
40
+ const results = await search({
41
+ client,
42
+ tableName: providerConfig.tableName,
43
+ query: "deployment checklist",
44
+ limit: 5,
45
+ embeddingOptions: {
46
+ ...providerConfig.embeddingOptions,
47
+ intent: "query",
48
+ },
49
+ });
50
+ ```
51
+
52
+ ## Provider Presets
53
+
54
+ These presets keep credentials out of the source file while making dimensions and table names explicit. Hosted presets send content to external providers and may incur provider charges when you run indexing or search.
55
+
56
+ ```ts
57
+ const providerPresets = {
58
+ local: {
59
+ tableName: "articles_local_384",
60
+ dimensions: 384,
61
+ embeddingOptions: {
62
+ provider: "local" as const,
63
+ },
64
+ },
65
+ cloudflare: {
66
+ tableName: "articles_cf_bgem3_1024",
67
+ dimensions: 1024,
68
+ embeddingOptions: {
69
+ provider: "cloudflare" as const,
70
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
71
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
72
+ dimensions: 1024,
73
+ },
74
+ },
75
+ mistral: {
76
+ tableName: "articles_mistral_1024",
77
+ dimensions: 1024,
78
+ embeddingOptions: {
79
+ provider: "mistral" as const,
80
+ apiKey: process.env.MISTRAL_API_KEY,
81
+ dimensions: 1024,
82
+ },
83
+ },
84
+ gemini: {
85
+ tableName: "articles_gemini_3072",
86
+ dimensions: 3072,
87
+ embeddingOptions: {
88
+ provider: "gemini" as const,
89
+ apiKey: process.env.GEMINI_API_KEY,
90
+ dimensions: 3072,
91
+ },
92
+ },
93
+ openai: {
94
+ tableName: "articles_openai_1536",
95
+ dimensions: 1536,
96
+ embeddingOptions: {
97
+ provider: "openai" as const,
98
+ apiKey: process.env.OPENAI_API_KEY,
99
+ dimensions: 1536,
100
+ },
101
+ },
102
+ openaiCompatible: {
103
+ tableName: "articles_tei_1024",
104
+ dimensions: 1024,
105
+ embeddingOptions: {
106
+ provider: "openai-compatible" as const,
107
+ baseUrl: process.env.EMBEDDING_BASE_URL,
108
+ model: process.env.EMBEDDING_MODEL,
109
+ dimensions: 1024,
110
+ apiKey: process.env.EMBEDDING_API_KEY,
111
+ batchSize: 32,
112
+ },
113
+ },
114
+ } as const;
115
+ ```
116
+
117
+ Pick one preset and use its `tableName` and `dimensions` all the way through `createTable()`, `indexContent()`, and `search()`. Do not point two providers or two dimension counts at the same table.
5
118
 
6
119
  ## Astro Search Endpoint
7
120
 
@@ -24,8 +137,10 @@ export const POST: APIRoute = async ({ request }) => {
24
137
  client,
25
138
  query,
26
139
  limit,
140
+ tableName: "articles_local_384",
27
141
  embeddingOptions: {
28
142
  provider: "local",
143
+ intent: "query",
29
144
  },
30
145
  });
31
146
 
@@ -47,14 +162,14 @@ const client = createClient({
47
162
  });
48
163
 
49
164
  export async function getStaticPaths() {
50
- const articles = await getAllArticles(client);
165
+ const articles = await getAllArticles(client, "articles_local_384");
51
166
 
52
167
  return articles.map((article) => ({
53
168
  params: { slug: article.slug },
54
169
  }));
55
170
  }
56
171
 
57
- const article = await getArticleBySlug(client, "guides/getting-started");
172
+ const article = await getArticleBySlug(client, "guides/getting-started", "articles_local_384");
58
173
  ```
59
174
 
60
175
  ## Next.js Route Handler
@@ -76,8 +191,10 @@ export async function POST(request: NextRequest) {
76
191
  client,
77
192
  query,
78
193
  limit,
194
+ tableName: "articles_local_384",
79
195
  embeddingOptions: {
80
196
  provider: "local",
197
+ intent: "query",
81
198
  },
82
199
  });
83
200
 
@@ -97,7 +214,7 @@ const client = createClient({
97
214
  });
98
215
 
99
216
  export async function generateStaticParams() {
100
- const articles = await getAllArticles(client);
217
+ const articles = await getAllArticles(client, "articles_local_384");
101
218
 
102
219
  return articles.map((article) => ({
103
220
  slug: article.slug,
@@ -106,7 +223,7 @@ export async function generateStaticParams() {
106
223
 
107
224
  export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
108
225
  const { slug } = await params;
109
- const article = await getArticleBySlug(client, slug);
226
+ const article = await getArticleBySlug(client, slug, "articles_local_384");
110
227
 
111
228
  return <article>{article?.title}</article>;
112
229
  }
@@ -114,8 +231,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
114
231
 
115
232
  ## Build-Time Index Script
116
233
 
117
- A small script is usually enough to rebuild the index before a site build.
118
-
119
234
  ```ts
120
235
  import { createClient } from "@libsql/client";
121
236
  import { createTable, indexContent } from "libsql-search";
@@ -125,48 +240,113 @@ const client = createClient({
125
240
  authToken: process.env.TURSO_AUTH_TOKEN!,
126
241
  });
127
242
 
128
- const embeddingProvider =
129
- process.env.EMBEDDING_PROVIDER as
130
- | "local"
131
- | "cloudflare"
132
- | "mistral"
133
- | "gemini"
134
- | "openai"
135
- | "openai-compatible"
136
- | undefined;
137
-
138
- const dimensionsByProvider = {
139
- local: 384,
140
- cloudflare: 1024,
141
- mistral: 1024,
142
- gemini: 3072,
143
- openai: 1536,
243
+ const providerPresets = {
244
+ local: {
245
+ tableName: "articles_local_384",
246
+ dimensions: 384,
247
+ embeddingOptions: {
248
+ provider: "local" as const,
249
+ },
250
+ },
251
+ cloudflare: {
252
+ tableName: "articles_cf_bgem3_1024",
253
+ dimensions: 1024,
254
+ embeddingOptions: {
255
+ provider: "cloudflare" as const,
256
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
257
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
258
+ dimensions: 1024,
259
+ },
260
+ },
261
+ mistral: {
262
+ tableName: "articles_mistral_1024",
263
+ dimensions: 1024,
264
+ embeddingOptions: {
265
+ provider: "mistral" as const,
266
+ apiKey: process.env.MISTRAL_API_KEY,
267
+ dimensions: 1024,
268
+ },
269
+ },
270
+ gemini: {
271
+ tableName: "articles_gemini_3072",
272
+ dimensions: 3072,
273
+ embeddingOptions: {
274
+ provider: "gemini" as const,
275
+ apiKey: process.env.GEMINI_API_KEY,
276
+ dimensions: 3072,
277
+ },
278
+ },
279
+ openai: {
280
+ tableName: "articles_openai_1536",
281
+ dimensions: 1536,
282
+ embeddingOptions: {
283
+ provider: "openai" as const,
284
+ apiKey: process.env.OPENAI_API_KEY,
285
+ dimensions: 1536,
286
+ },
287
+ },
288
+ "openai-compatible": {
289
+ tableName: "articles_tei_1024",
290
+ dimensions: 1024,
291
+ embeddingOptions: {
292
+ provider: "openai-compatible" as const,
293
+ baseUrl: process.env.EMBEDDING_BASE_URL,
294
+ model: process.env.EMBEDDING_MODEL,
295
+ dimensions: 1024,
296
+ apiKey: process.env.EMBEDDING_API_KEY,
297
+ batchSize: 32,
298
+ },
299
+ },
144
300
  } as const;
145
301
 
146
- const embeddingDimensions =
147
- embeddingProvider === "openai-compatible"
148
- ? Number(process.env.EMBEDDING_DIMENSIONS)
149
- : dimensionsByProvider[embeddingProvider ?? "local"];
302
+ const provider = process.env.EMBEDDING_PROVIDER ?? "local";
303
+
304
+ if (!(provider in providerPresets)) {
305
+ throw new Error(
306
+ `Unknown EMBEDDING_PROVIDER: ${provider}. Expected one of ${Object.keys(providerPresets).join(", ")}`
307
+ );
308
+ }
309
+
310
+ const preset = providerPresets[provider as keyof typeof providerPresets];
150
311
 
151
- await createTable(client, "articles", embeddingDimensions);
312
+ await createTable(client, preset.tableName, preset.dimensions);
152
313
 
153
314
  await indexContent({
154
315
  client,
155
316
  contentPath: "./content",
317
+ tableName: preset.tableName,
156
318
  embeddingOptions: {
157
- provider: embeddingProvider,
158
- baseUrl: process.env.EMBEDDING_BASE_URL,
159
- model: process.env.EMBEDDING_MODEL,
160
- dimensions: embeddingDimensions,
319
+ ...preset.embeddingOptions,
320
+ intent: "document",
161
321
  },
162
322
  });
163
323
  ```
164
324
 
165
- Pair this with your framework build command so indexed content and deployed code
166
- stay in sync.
325
+ ## CI-safe Provider Coverage
326
+
327
+ Keep routine CI on mocks only:
328
+
329
+ ```ts
330
+ import { vi } from "vitest";
331
+ import { generateEmbeddings } from "libsql-search";
332
+
333
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
334
+ ok: true,
335
+ headers: new Headers(),
336
+ json: async () => ({
337
+ data: [
338
+ { index: 0, embedding: [1, 2] },
339
+ { index: 1, embedding: [3, 4] },
340
+ ],
341
+ }),
342
+ }));
343
+
344
+ await generateEmbeddings(["doc one", "doc two"], {
345
+ provider: "openai-compatible",
346
+ baseUrl: "https://tei.example.internal/v1",
347
+ model: "tei-model",
348
+ dimensions: 2,
349
+ });
350
+ ```
167
351
 
168
- When you use `openai-compatible`, keep `EMBEDDING_BASE_URL` as trusted
169
- server-side configuration, not user request input. Recreate the vector table or
170
- rebuild into a separate table whenever the provider, endpoint, model, or
171
- dimension count changes; stored vectors and query vectors must come from the
172
- same embedding space.
352
+ The repository test suite should not require real provider credentials. See [Testing guidance](./TESTING.md) for local model mocks, Gemini SDK mocks, and validation-before-network assertions.
@@ -0,0 +1,176 @@
1
+ # Migration And Reindexing
2
+
3
+ Changing an embedding provider is not just a config flip. Stored vectors and query vectors must stay in the same embedding space for search quality to hold.
4
+
5
+ ## What The Table Stores
6
+
7
+ `createTable(client, tableName, dimensions)` creates an `embedding` column with the exact Turso/libSQL vector type:
8
+
9
+ ```sql
10
+ embedding F32_BLOB(dimensions)
11
+ ```
12
+
13
+ That width is fixed by the table schema. `CREATE TABLE IF NOT EXISTS` does not resize an existing vector column.
14
+
15
+ References:
16
+
17
+ - [Turso AI and embeddings](https://docs.turso.tech/features/ai-and-embeddings)
18
+ - [Gemini model versions](https://ai.google.dev/gemini-api/docs/embeddings#model-versions)
19
+
20
+ ## Rules
21
+
22
+ - if dimensions change, create a new table or recreate the old table, then fully re-embed
23
+ - if dimensions stay the same but provider, model, endpoint, model revision, pooling, normalization, or input formatting changes, fully reindex anyway
24
+ - never mix two embedding spaces in one table
25
+ - prefer a parallel table migration because `indexContent()` replaces the whole target table, so an in-place rebuild leaves no way back to the old vectors
26
+
27
+ In practice, this means:
28
+
29
+ - `384 local` and `1024 Mistral` can never share a table
30
+ - `1024 Cloudflare` and `1024 Mistral` still need separate rebuilds because equal width does not make the vectors compatible
31
+ - a custom endpoint change at the same width still needs a new table because the model or serving stack may have changed
32
+
33
+ ## Safe Migration Pattern
34
+
35
+ 1. Pick a new table name that makes the provider and dimensions obvious.
36
+ 2. Create that table with the target width.
37
+ 3. Reindex all content into the new table.
38
+ 4. Run search quality checks against the new table.
39
+ 5. Switch application reads and writes to the new table.
40
+ 6. Retire the old table in a separate cleanup step.
41
+
42
+ Step 3 is all or nothing. `indexContent()` throws `IndexingError` and leaves the target table untouched when a file or the replacement transaction fails, so a failed migration step can be retried without cleanup. See [Indexing and operational behavior](./INDEXING.md) for `failurePolicy` and `allowEmptyIndex`.
43
+
44
+ Example:
45
+
46
+ ```ts
47
+ await createTable(client, "articles_gemini2_3072", 3072);
48
+
49
+ await indexContent({
50
+ client,
51
+ contentPath: "./content",
52
+ tableName: "articles_gemini2_3072",
53
+ embeddingOptions: {
54
+ provider: "gemini",
55
+ apiKey: process.env.GEMINI_API_KEY,
56
+ dimensions: 3072,
57
+ intent: "document",
58
+ },
59
+ });
60
+ ```
61
+
62
+ Then query the new table explicitly during validation:
63
+
64
+ ```ts
65
+ const results = await search({
66
+ client,
67
+ tableName: "articles_gemini2_3072",
68
+ query: "deployment checklist",
69
+ embeddingOptions: {
70
+ provider: "gemini",
71
+ apiKey: process.env.GEMINI_API_KEY,
72
+ dimensions: 3072,
73
+ intent: "query",
74
+ },
75
+ });
76
+ ```
77
+
78
+ ## Common Migration Paths
79
+
80
+ | From | To | Why a rebuild is required | Recommended table move |
81
+ | --- | --- | --- | --- |
82
+ | Legacy padded local `768` | Native local `384` | Old tables stored `384` model values plus zero padding; current local provider is a native `384`-dimension space | Build into `articles_local_384`, validate, then retire the legacy table |
83
+ | Any `768` space | Any `1024` space | Width changes from `F32_BLOB(768)` to `F32_BLOB(1024)` | Create a new `1024` table and reindex |
84
+ | Cloudflare `1024` | Mistral `1024` | Width stays the same, but provider/model space changes | Use a parallel `1024` table such as `articles_mistral_1024` |
85
+ | Mistral `1024` | Cloudflare `1024` | Same reason in reverse | Use a parallel `1024` table such as `articles_cf_bgem3_1024` |
86
+ | Any `1024` space | OpenAI `1536` | Width changes and model changes | Create `articles_openai_1536` and reindex |
87
+ | OpenAI `1536` | OpenAI or Gemini `3072` | Width changes to `3072` | Create a new `3072` table and reindex |
88
+ | Gemini legacy `text-embedding-004` at `768` | Gemini `gemini-embedding-2` at `768` | Same width, but the upstream model changed and the adapter now distinguishes document/query formatting | Build a parallel `articles_gemini2_768` table |
89
+ | Any custom endpoint/model | Any other custom endpoint/model, same width or different width | Endpoint, model, or serving revision may change the embedding space even when dimensions match | Always create a new table named for the target provider/model and reindex |
90
+
91
+ ## Scenario Notes
92
+
93
+ ### Legacy Local `768` To Native Local `384`
94
+
95
+ Earlier local migrations sometimes relied on zero padding to fit a `768`-wide table. The current local adapter emits the model's native `384` dimensions and rejects any other local dimension count.
96
+
97
+ Safe path:
98
+
99
+ ```ts
100
+ await createTable(client, "articles_local_384", 384);
101
+ ```
102
+
103
+ Reindex into `articles_local_384`; do not keep writing new local vectors into the legacy padded table.
104
+
105
+ ### `768` To `1024`
106
+
107
+ Any move from `768` dimensions to `1024` dimensions changes the schema width. Examples include a legacy local table moving to Cloudflare or Mistral.
108
+
109
+ ```ts
110
+ await createTable(client, "articles_mistral_1024", 1024);
111
+ ```
112
+
113
+ ### Cloudflare `1024` To Mistral `1024`
114
+
115
+ This is the main same-width example. The schema width can stay `1024`, but the vector space still changes.
116
+
117
+ ```ts
118
+ await createTable(client, "articles_cf_bgem3_1024", 1024);
119
+ await createTable(client, "articles_mistral_1024", 1024);
120
+ ```
121
+
122
+ Keep both tables side by side during validation. Do not clear the Cloudflare table until the Mistral table is validated in production-like queries.
123
+
124
+ ### `1024` To OpenAI `1536`
125
+
126
+ ```ts
127
+ await createTable(client, "articles_openai_1536", 1536);
128
+ ```
129
+
130
+ This is both a width change and a provider/model change.
131
+
132
+ ### `1536` To `3072`
133
+
134
+ Applies when moving from OpenAI `1536` to OpenAI `3072`, or from OpenAI `1536` to Gemini `3072`.
135
+
136
+ ```ts
137
+ await createTable(client, "articles_openai_3072", 3072);
138
+ await createTable(client, "articles_gemini_3072", 3072);
139
+ ```
140
+
141
+ Pick one target space and validate it before switching reads.
142
+
143
+ ### Gemini Older Model `768` To Gemini 2 `768`
144
+
145
+ Even if you stay at `768`, rebuild because `text-embedding-004` has been replaced by `gemini-embedding-2`, and the current adapter uses different text formatting for document versus query intent.
146
+
147
+ ```ts
148
+ await createTable(client, "articles_gemini2_768", 768);
149
+ ```
150
+
151
+ ### Custom Endpoint Or Model Change
152
+
153
+ Treat any `openai-compatible` move as a new embedding space:
154
+
155
+ - TEI model upgrade
156
+ - base URL change
157
+ - serving stack change
158
+ - pooling or normalization change
159
+ - gateway rewrite that changes input formatting
160
+
161
+ Example:
162
+
163
+ ```ts
164
+ await createTable(client, "articles_tei_bge_1024_v2", 1024);
165
+ ```
166
+
167
+ ## Quality Checks Before Cutover
168
+
169
+ Run a small set of real queries against both old and new tables before switching:
170
+
171
+ - high-value navigation queries
172
+ - acronym or product-name queries
173
+ - queries that depend on tags or frontmatter wording
174
+ - long-tail queries that previously returned useful content
175
+
176
+ If the new table looks worse, keep the old table live while you inspect chunk size, source content, provider choice, and dimensionality.