libsql-search 0.8.0 → 0.9.1

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
@@ -26,11 +26,21 @@ npm install libsql-search @libsql/client
26
26
  ```
27
27
 
28
28
  ```bash
29
- deno add jsr:@logan/libsql-search npm:@libsql/client
29
+ deno add jsr:@logan/libsql-search npm:@libsql/client@^0.17.0
30
30
  ```
31
31
 
32
32
  For npm usage, the package requires Node `>=22.12.0`.
33
33
 
34
+ **On npm/pnpm**, the peer range is `@libsql/client ^0.15.0 || ^0.17.0`. Both lines are supported: every behavior this package depends on — `vector_top_k()`'s result shape, the vector index error wording that `search()` matches on, and transactional `batch()` rollback — is identical across them, so an existing `0.15.x` install does not have to move. There is no `0.16.x` line upstream, which is why the range is a disjunction rather than a span. The packaged build is smoke-tested against both arms on every release, at the newest release each arm admits (currently `0.15.15` and `0.17.4`).
35
+
36
+ **On JSR/Deno the range does not apply to you.** `deno.json` declares no dependency on `@libsql/client` — this package imports only its *types* — so the client you `deno add` separately is constrained by nothing on our side, and a plain `deno add npm:@libsql/client` will silently take whatever is newest, including a future major we have never tested. Deno also cannot express our range: `npm:@libsql/client@^0.15.0 || ^0.17.0` is a parse error, as is any `>=`/`<` span. Pin an arm yourself instead:
37
+
38
+ ```bash
39
+ deno add jsr:@logan/libsql-search npm:@libsql/client@^0.17.0
40
+ ```
41
+
42
+ Note for `0.17.x`: the client no longer exports `./package.json`, so `require("@libsql/client/package.json")` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. Nothing in this package reads it, but tooling of yours that inspected the client manifest by specifier needs a direct `node_modules` path instead. See [`@libsql/client` version differences](./docs/TROUBLESHOOTING.md#libsqlclient-version-differences) for the other upgrade-visible change.
43
+
34
44
  ## Quick Start
35
45
 
36
46
  This example uses the default local provider. Local embeddings run in-process after the initial model download and cache warmup; they are not automatically air-gapped.
@@ -74,13 +84,33 @@ console.log(results.map((result) => ({
74
84
 
75
85
  Important behavior:
76
86
 
77
- - Call `createTable()` before indexing or searching.
87
+ - Call `createTable()` before indexing or searching. It creates the `<tableName>_embedding_idx` vector index that `search()` needs.
78
88
  - Keep table width, provider, and dimensions aligned across create/index/query.
79
89
  - `indexContent()` embeds every document before it touches the database, then replaces the table in one transaction, so a failed rebuild leaves the previous index intact.
80
90
  - `indexContent()` throws `IndexingError` when a file fails; pass `failurePolicy: "skip"` to rebuild from the remaining files.
81
91
  - `indexContent()` throws `IndexingError` when no source files are found; pass `allowEmptyIndex: true` to intentionally empty the index.
82
92
  - Hosted providers send indexed and queried text to external services and may incur provider charges.
83
93
 
94
+ ## Search Accuracy And Performance
95
+
96
+ `search()` queries the `<tableName>_embedding_idx` vector index through libSQL's `vector_top_k()`. It does not score every row, so query cost no longer grows linearly with the size of the index.
97
+
98
+ That index is an approximate-nearest-neighbor structure, so **the default search path is approximate and can miss a true nearest neighbor.** To limit the loss, `search()` over-fetches candidates from the index, recomputes the true cosine distance for each, and orders exactly by `(distance, id)` before trimming to `limit`. Distances on returned rows are always exact, and result ordering is fully deterministic — including when two rows tie — even though the index's own candidate order is not.
99
+
100
+ Two options control the trade-off:
101
+
102
+ ```ts
103
+ // Widen the index probe to raise recall (default: max(limit * 4, 32))
104
+ await search({ client, query, limit: 10, candidates: 200 });
105
+
106
+ // Bypass the index entirely: exact, but linear in table size
107
+ await search({ client, query, exact: true });
108
+ ```
109
+
110
+ `exact: true` is the only way to guarantee exactness. Use it for small corpora, for correctness checks against the index path, and for tables that have no vector index.
111
+
112
+ Requirements: `vector_top_k()` and `libsql_vector_idx()` need a libSQL build with native vector support. The peer dependency is `@libsql/client ^0.15.0 || ^0.17.0`, verified against `0.15.15` and `0.17.4`; remote Turso/libSQL servers must support vector indexes as well. See the [API reference](./docs/API.md#searchoptions) for full semantics, and [Indexing and operations](./docs/INDEXING.md) for tables created before the index existed.
113
+
84
114
  ## Providers
85
115
 
86
116
  Built-in providers:
package/dist/index.cjs CHANGED
@@ -788,6 +788,9 @@ function prepareTextForEmbedding(fields) {
788
788
  const SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
789
789
  const DEFAULT_SEARCH_LIMIT = 10;
790
790
  const MAX_SEARCH_LIMIT = 100;
791
+ const DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = 4;
792
+ const MIN_SEARCH_CANDIDATES = 32;
793
+ const MAX_SEARCH_CANDIDATES = MAX_SEARCH_LIMIT * 10;
791
794
  function validateSqlIdentifier(identifier, name = "identifier") {
792
795
  if (typeof identifier !== "string" || !SQL_IDENTIFIER_PATTERN.test(identifier)) {
793
796
  throw new Error(
@@ -806,6 +809,23 @@ function normalizeSearchLimit(limit = DEFAULT_SEARCH_LIMIT) {
806
809
  }
807
810
  return limit;
808
811
  }
812
+ function defaultSearchCandidates(limit) {
813
+ return Math.min(
814
+ Math.max(limit * DEFAULT_SEARCH_CANDIDATE_MULTIPLIER, MIN_SEARCH_CANDIDATES),
815
+ MAX_SEARCH_CANDIDATES
816
+ );
817
+ }
818
+ function normalizeSearchCandidates(candidates, limit) {
819
+ if (candidates === void 0) {
820
+ return defaultSearchCandidates(limit);
821
+ }
822
+ if (typeof candidates !== "number" || !Number.isFinite(candidates) || !Number.isInteger(candidates) || candidates < limit || candidates > MAX_SEARCH_CANDIDATES) {
823
+ throw new Error(
824
+ `Invalid search candidates: expected an integer from the search limit (${limit}) to ${MAX_SEARCH_CANDIDATES}`
825
+ );
826
+ }
827
+ return candidates;
828
+ }
809
829
  function normalizeVectorDimensions(dimensions) {
810
830
  if (typeof dimensions !== "number" || !Number.isFinite(dimensions) || !Number.isInteger(dimensions) || dimensions < 1) {
811
831
  throw new Error("Invalid vector dimensions: expected a positive integer");
@@ -1096,39 +1116,102 @@ async function createTable(client, tableName = "articles", dimensions = 384) {
1096
1116
  `);
1097
1117
  }
1098
1118
 
1119
+ const RESULT_COLUMNS = "a.id, a.slug, a.title, a.content, a.folder, a.tags, a.created_at";
1120
+ const MISSING_VECTOR_INDEX_PATTERNS = [
1121
+ /failed to parse vector index parameters/i
1122
+ ];
1123
+ const NO_VECTOR_SUPPORT_PATTERNS = [
1124
+ /no such table:\s*vector_top_k/i
1125
+ ];
1099
1126
  async function search(options) {
1100
1127
  const {
1101
1128
  client,
1102
1129
  query,
1103
1130
  limit = 10,
1104
1131
  tableName = "articles",
1105
- embeddingOptions = {}
1132
+ embeddingOptions = {},
1133
+ candidates,
1134
+ exact = false
1106
1135
  } = options;
1107
1136
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
1108
1137
  const resultLimit = normalizeSearchLimit(limit);
1138
+ const embeddingIndexName = validateSqlIdentifier(
1139
+ `${tableName}_embedding_idx`,
1140
+ "embedding index name"
1141
+ );
1142
+ const candidateCount = normalizeSearchCandidates(candidates, resultLimit);
1109
1143
  const queryEmbedding = await generateEmbedding(query, {
1110
1144
  ...embeddingOptions,
1111
1145
  intent: embeddingOptions.intent ?? "query"
1112
1146
  });
1113
- const results = await client.execute({
1147
+ const queryVector = JSON.stringify(queryEmbedding);
1148
+ const results = exact ? await executeExactSearch(client, quotedTableName, queryVector, resultLimit) : await executeIndexSearch(
1149
+ client,
1150
+ quotedTableName,
1151
+ embeddingIndexName,
1152
+ queryVector,
1153
+ candidateCount,
1154
+ resultLimit
1155
+ );
1156
+ return results.rows.map(toSearchResult);
1157
+ }
1158
+ async function executeIndexSearch(client, quotedTableName, embeddingIndexName, queryVector, candidateCount, resultLimit) {
1159
+ try {
1160
+ return await client.execute({
1161
+ sql: `
1162
+ SELECT
1163
+ ${RESULT_COLUMNS},
1164
+ vector_distance_cos(a.embedding, vector(:queryVector)) as distance
1165
+ FROM vector_top_k(:indexName, vector(:queryVector), :candidates) AS v
1166
+ JOIN ${quotedTableName} a ON a.rowid = v.id
1167
+ ORDER BY distance, a.id
1168
+ LIMIT :resultLimit
1169
+ `,
1170
+ args: {
1171
+ queryVector,
1172
+ indexName: embeddingIndexName,
1173
+ candidates: candidateCount,
1174
+ resultLimit
1175
+ }
1176
+ });
1177
+ } catch (error) {
1178
+ throw wrapIndexPathError(error, embeddingIndexName);
1179
+ }
1180
+ }
1181
+ async function executeExactSearch(client, quotedTableName, queryVector, resultLimit) {
1182
+ return client.execute({
1114
1183
  sql: `
1115
1184
  SELECT
1116
- id,
1117
- slug,
1118
- title,
1119
- content,
1120
- folder,
1121
- tags,
1122
- created_at,
1123
- vector_distance_cos(embedding, vector(?)) as distance
1124
- FROM ${quotedTableName}
1125
- WHERE embedding IS NOT NULL
1126
- ORDER BY distance
1127
- LIMIT ?
1185
+ ${RESULT_COLUMNS},
1186
+ vector_distance_cos(a.embedding, vector(:queryVector)) as distance
1187
+ FROM ${quotedTableName} a
1188
+ WHERE a.embedding IS NOT NULL
1189
+ ORDER BY distance, a.id
1190
+ LIMIT :resultLimit
1128
1191
  `,
1129
- args: [JSON.stringify(queryEmbedding), resultLimit]
1192
+ args: { queryVector, resultLimit }
1130
1193
  });
1131
- return results.rows.map((row) => ({
1194
+ }
1195
+ function wrapIndexPathError(error, embeddingIndexName) {
1196
+ if (!(error instanceof Error)) {
1197
+ return error;
1198
+ }
1199
+ if (MISSING_VECTOR_INDEX_PATTERNS.some((pattern) => pattern.test(error.message))) {
1200
+ return new Error(
1201
+ `Vector index "${embeddingIndexName}" could not be used for search. createTable() creates this index; a table created before it existed, or created by hand, will not have it. Create it with CREATE INDEX IF NOT EXISTS "${embeddingIndexName}" ON <table>(libsql_vector_idx(embedding)), or pass exact: true to search without the index.`,
1202
+ { cause: error }
1203
+ );
1204
+ }
1205
+ if (NO_VECTOR_SUPPORT_PATTERNS.some((pattern) => pattern.test(error.message))) {
1206
+ return new Error(
1207
+ `This libSQL deployment has no vector index support: vector_top_k() is unavailable. Pass exact: true to search on the full-scan path.`,
1208
+ { cause: error }
1209
+ );
1210
+ }
1211
+ return error;
1212
+ }
1213
+ function toSearchResult(row) {
1214
+ return {
1132
1215
  id: row.id,
1133
1216
  slug: row.slug,
1134
1217
  title: row.title,
@@ -1137,7 +1220,7 @@ async function search(options) {
1137
1220
  tags: JSON.parse(row.tags || "[]"),
1138
1221
  distance: row.distance,
1139
1222
  created_at: row.created_at
1140
- }));
1223
+ };
1141
1224
  }
1142
1225
  async function getAllArticles(client, tableName = "articles") {
1143
1226
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -1211,7 +1294,10 @@ async function getFolders(client, tableName = "articles") {
1211
1294
  return results.rows.map((row) => row.folder);
1212
1295
  }
1213
1296
 
1297
+ exports.DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = DEFAULT_SEARCH_CANDIDATE_MULTIPLIER;
1214
1298
  exports.IndexingError = IndexingError;
1299
+ exports.MAX_SEARCH_CANDIDATES = MAX_SEARCH_CANDIDATES;
1300
+ exports.MIN_SEARCH_CANDIDATES = MIN_SEARCH_CANDIDATES;
1215
1301
  exports.createEmbeddingProvider = createEmbeddingProvider;
1216
1302
  exports.createTable = createTable;
1217
1303
  exports.generateEmbedding = generateEmbedding;
package/dist/index.d.ts CHANGED
@@ -161,6 +161,27 @@ declare function indexContent(options: IndexerOptions): Promise<IndexResult>;
161
161
  */
162
162
  declare function createTable(client: Client, tableName?: string, dimensions?: number): Promise<void>;
163
163
 
164
+ /**
165
+ * Multiplier applied to `limit` when deriving the default candidate count.
166
+ *
167
+ * The vector index is approximate, so the default search over-fetches
168
+ * candidates and re-ranks them exactly. Four candidates per requested result
169
+ * recovers most of the recall an ANN probe loses without materially changing
170
+ * the cost of the join.
171
+ */
172
+ declare const DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = 4;
173
+ /**
174
+ * Floor for the default candidate count. Small limits would otherwise probe
175
+ * the index too shallowly for the exact re-rank to have anything to work with.
176
+ */
177
+ declare const MIN_SEARCH_CANDIDATES = 32;
178
+ /**
179
+ * Ceiling for an explicit `candidates` value. Mirrors {@link MAX_SEARCH_LIMIT},
180
+ * scaled by an order of magnitude, so the widest legal probe still stays well
181
+ * short of a full table scan.
182
+ */
183
+ declare const MAX_SEARCH_CANDIDATES: number;
184
+
164
185
  /**
165
186
  * Vector search functionality
166
187
  */
@@ -171,6 +192,22 @@ interface SearchOptions {
171
192
  limit?: number;
172
193
  tableName?: string;
173
194
  embeddingOptions?: EmbeddingOptions;
195
+ /**
196
+ * How many candidates to pull from the vector index before the exact
197
+ * re-rank. Must be an integer from `limit` through {@link MAX_SEARCH_CANDIDATES}.
198
+ *
199
+ * Defaults to `max(limit * 4, 32)`.
200
+ *
201
+ * Has no effect when `exact` is `true`, but is still validated: an
202
+ * out-of-range value throws on both paths rather than being quietly accepted
203
+ * on one of them.
204
+ */
205
+ candidates?: number;
206
+ /**
207
+ * Bypass the vector index and score every row instead. Exact but linear in
208
+ * table size. Defaults to `false`.
209
+ */
210
+ exact?: boolean;
174
211
  }
175
212
  interface SearchResult {
176
213
  id: number;
@@ -183,7 +220,17 @@ interface SearchResult {
183
220
  created_at: string;
184
221
  }
185
222
  /**
186
- * Perform semantic search using vector similarity
223
+ * Perform semantic search using vector similarity.
224
+ *
225
+ * By default this queries the `<tableName>_embedding_idx` vector index, which
226
+ * is an approximate-nearest-neighbor structure: the candidate set it returns is
227
+ * not guaranteed to contain the true nearest rows, and is not stable across
228
+ * runs when distances tie. To limit both effects the query over-fetches
229
+ * `candidates` rows, recomputes the true cosine distance for each, and orders
230
+ * exactly by `(distance, id)`. The returned ordering is therefore fully
231
+ * deterministic even though the candidate set is not.
232
+ *
233
+ * Pass `exact: true` for a guaranteed-exact full scan.
187
234
  */
188
235
  declare function search(options: SearchOptions): Promise<SearchResult[]>;
189
236
  /**
@@ -226,5 +273,5 @@ declare function getArticlesByFolder(client: Client, folder: string, tableName?:
226
273
  */
227
274
  declare function getFolders(client: Client, tableName?: string): Promise<string[]>;
228
275
 
229
- export { IndexingError, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
276
+ export { DEFAULT_SEARCH_CANDIDATE_MULTIPLIER, IndexingError, MAX_SEARCH_CANDIDATES, MIN_SEARCH_CANDIDATES, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
230
277
  export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexFailure, IndexFailurePolicy, IndexFailureStage, IndexResult, IndexedDocument, IndexerOptions, IndexingErrorPhase, SearchOptions, SearchResult };
package/dist/index.esm.js CHANGED
@@ -786,6 +786,9 @@ function prepareTextForEmbedding(fields) {
786
786
  const SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
787
787
  const DEFAULT_SEARCH_LIMIT = 10;
788
788
  const MAX_SEARCH_LIMIT = 100;
789
+ const DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = 4;
790
+ const MIN_SEARCH_CANDIDATES = 32;
791
+ const MAX_SEARCH_CANDIDATES = MAX_SEARCH_LIMIT * 10;
789
792
  function validateSqlIdentifier(identifier, name = "identifier") {
790
793
  if (typeof identifier !== "string" || !SQL_IDENTIFIER_PATTERN.test(identifier)) {
791
794
  throw new Error(
@@ -804,6 +807,23 @@ function normalizeSearchLimit(limit = DEFAULT_SEARCH_LIMIT) {
804
807
  }
805
808
  return limit;
806
809
  }
810
+ function defaultSearchCandidates(limit) {
811
+ return Math.min(
812
+ Math.max(limit * DEFAULT_SEARCH_CANDIDATE_MULTIPLIER, MIN_SEARCH_CANDIDATES),
813
+ MAX_SEARCH_CANDIDATES
814
+ );
815
+ }
816
+ function normalizeSearchCandidates(candidates, limit) {
817
+ if (candidates === void 0) {
818
+ return defaultSearchCandidates(limit);
819
+ }
820
+ if (typeof candidates !== "number" || !Number.isFinite(candidates) || !Number.isInteger(candidates) || candidates < limit || candidates > MAX_SEARCH_CANDIDATES) {
821
+ throw new Error(
822
+ `Invalid search candidates: expected an integer from the search limit (${limit}) to ${MAX_SEARCH_CANDIDATES}`
823
+ );
824
+ }
825
+ return candidates;
826
+ }
807
827
  function normalizeVectorDimensions(dimensions) {
808
828
  if (typeof dimensions !== "number" || !Number.isFinite(dimensions) || !Number.isInteger(dimensions) || dimensions < 1) {
809
829
  throw new Error("Invalid vector dimensions: expected a positive integer");
@@ -1094,39 +1114,102 @@ async function createTable(client, tableName = "articles", dimensions = 384) {
1094
1114
  `);
1095
1115
  }
1096
1116
 
1117
+ const RESULT_COLUMNS = "a.id, a.slug, a.title, a.content, a.folder, a.tags, a.created_at";
1118
+ const MISSING_VECTOR_INDEX_PATTERNS = [
1119
+ /failed to parse vector index parameters/i
1120
+ ];
1121
+ const NO_VECTOR_SUPPORT_PATTERNS = [
1122
+ /no such table:\s*vector_top_k/i
1123
+ ];
1097
1124
  async function search(options) {
1098
1125
  const {
1099
1126
  client,
1100
1127
  query,
1101
1128
  limit = 10,
1102
1129
  tableName = "articles",
1103
- embeddingOptions = {}
1130
+ embeddingOptions = {},
1131
+ candidates,
1132
+ exact = false
1104
1133
  } = options;
1105
1134
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
1106
1135
  const resultLimit = normalizeSearchLimit(limit);
1136
+ const embeddingIndexName = validateSqlIdentifier(
1137
+ `${tableName}_embedding_idx`,
1138
+ "embedding index name"
1139
+ );
1140
+ const candidateCount = normalizeSearchCandidates(candidates, resultLimit);
1107
1141
  const queryEmbedding = await generateEmbedding(query, {
1108
1142
  ...embeddingOptions,
1109
1143
  intent: embeddingOptions.intent ?? "query"
1110
1144
  });
1111
- const results = await client.execute({
1145
+ const queryVector = JSON.stringify(queryEmbedding);
1146
+ const results = exact ? await executeExactSearch(client, quotedTableName, queryVector, resultLimit) : await executeIndexSearch(
1147
+ client,
1148
+ quotedTableName,
1149
+ embeddingIndexName,
1150
+ queryVector,
1151
+ candidateCount,
1152
+ resultLimit
1153
+ );
1154
+ return results.rows.map(toSearchResult);
1155
+ }
1156
+ async function executeIndexSearch(client, quotedTableName, embeddingIndexName, queryVector, candidateCount, resultLimit) {
1157
+ try {
1158
+ return await client.execute({
1159
+ sql: `
1160
+ SELECT
1161
+ ${RESULT_COLUMNS},
1162
+ vector_distance_cos(a.embedding, vector(:queryVector)) as distance
1163
+ FROM vector_top_k(:indexName, vector(:queryVector), :candidates) AS v
1164
+ JOIN ${quotedTableName} a ON a.rowid = v.id
1165
+ ORDER BY distance, a.id
1166
+ LIMIT :resultLimit
1167
+ `,
1168
+ args: {
1169
+ queryVector,
1170
+ indexName: embeddingIndexName,
1171
+ candidates: candidateCount,
1172
+ resultLimit
1173
+ }
1174
+ });
1175
+ } catch (error) {
1176
+ throw wrapIndexPathError(error, embeddingIndexName);
1177
+ }
1178
+ }
1179
+ async function executeExactSearch(client, quotedTableName, queryVector, resultLimit) {
1180
+ return client.execute({
1112
1181
  sql: `
1113
1182
  SELECT
1114
- id,
1115
- slug,
1116
- title,
1117
- content,
1118
- folder,
1119
- tags,
1120
- created_at,
1121
- vector_distance_cos(embedding, vector(?)) as distance
1122
- FROM ${quotedTableName}
1123
- WHERE embedding IS NOT NULL
1124
- ORDER BY distance
1125
- LIMIT ?
1183
+ ${RESULT_COLUMNS},
1184
+ vector_distance_cos(a.embedding, vector(:queryVector)) as distance
1185
+ FROM ${quotedTableName} a
1186
+ WHERE a.embedding IS NOT NULL
1187
+ ORDER BY distance, a.id
1188
+ LIMIT :resultLimit
1126
1189
  `,
1127
- args: [JSON.stringify(queryEmbedding), resultLimit]
1190
+ args: { queryVector, resultLimit }
1128
1191
  });
1129
- return results.rows.map((row) => ({
1192
+ }
1193
+ function wrapIndexPathError(error, embeddingIndexName) {
1194
+ if (!(error instanceof Error)) {
1195
+ return error;
1196
+ }
1197
+ if (MISSING_VECTOR_INDEX_PATTERNS.some((pattern) => pattern.test(error.message))) {
1198
+ return new Error(
1199
+ `Vector index "${embeddingIndexName}" could not be used for search. createTable() creates this index; a table created before it existed, or created by hand, will not have it. Create it with CREATE INDEX IF NOT EXISTS "${embeddingIndexName}" ON <table>(libsql_vector_idx(embedding)), or pass exact: true to search without the index.`,
1200
+ { cause: error }
1201
+ );
1202
+ }
1203
+ if (NO_VECTOR_SUPPORT_PATTERNS.some((pattern) => pattern.test(error.message))) {
1204
+ return new Error(
1205
+ `This libSQL deployment has no vector index support: vector_top_k() is unavailable. Pass exact: true to search on the full-scan path.`,
1206
+ { cause: error }
1207
+ );
1208
+ }
1209
+ return error;
1210
+ }
1211
+ function toSearchResult(row) {
1212
+ return {
1130
1213
  id: row.id,
1131
1214
  slug: row.slug,
1132
1215
  title: row.title,
@@ -1135,7 +1218,7 @@ async function search(options) {
1135
1218
  tags: JSON.parse(row.tags || "[]"),
1136
1219
  distance: row.distance,
1137
1220
  created_at: row.created_at
1138
- }));
1221
+ };
1139
1222
  }
1140
1223
  async function getAllArticles(client, tableName = "articles") {
1141
1224
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -1209,4 +1292,4 @@ async function getFolders(client, tableName = "articles") {
1209
1292
  return results.rows.map((row) => row.folder);
1210
1293
  }
1211
1294
 
1212
- export { IndexingError, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
1295
+ export { DEFAULT_SEARCH_CANDIDATE_MULTIPLIER, IndexingError, MAX_SEARCH_CANDIDATES, MIN_SEARCH_CANDIDATES, createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
package/docs/API.md CHANGED
@@ -19,6 +19,9 @@
19
19
  - `padEmbedding`
20
20
  - `prepareTextForEmbedding`
21
21
  - `IndexingError`
22
+ - `DEFAULT_SEARCH_CANDIDATE_MULTIPLIER`
23
+ - `MIN_SEARCH_CANDIDATES`
24
+ - `MAX_SEARCH_CANDIDATES`
22
25
 
23
26
  It also exports these types:
24
27
 
@@ -187,6 +190,8 @@ interface SearchOptions {
187
190
  limit?: number;
188
191
  tableName?: string;
189
192
  embeddingOptions?: EmbeddingOptions;
193
+ candidates?: number;
194
+ exact?: boolean;
190
195
  }
191
196
  ```
192
197
 
@@ -194,9 +199,67 @@ Defaults:
194
199
 
195
200
  - `limit`: `10`
196
201
  - `tableName`: `"articles"`
202
+ - `candidates`: `Math.max(limit * 4, 32)`
203
+ - `exact`: `false`
197
204
 
198
205
  `limit` must be an integer from `1` through `100`; invalid values are rejected before query embedding generation.
199
206
 
207
+ ### Search Is Approximate By Default
208
+
209
+ The default path queries the `<tableName>_embedding_idx` vector index through libSQL's `vector_top_k()`. **That index is an approximate-nearest-neighbor structure. It can miss a true nearest neighbor.** There is no configuration that makes the index path exact; only `exact: true` guarantees exactness.
210
+
211
+ To limit the accuracy loss, the default path over-fetches: it pulls `candidates` rows from the index, recomputes the true `vector_distance_cos` for each, and orders exactly by `(distance, id)` before trimming to `limit`. So:
212
+
213
+ - **Recall is approximate.** A row the index does not return as a candidate cannot appear in the results, no matter its true distance. Raising `candidates` raises recall.
214
+ - **Ranking within the candidate set is exact.** Distances on returned rows are always the true cosine distances, not index approximations.
215
+ - **Ordering is fully deterministic.** The `id` tiebreaker means two rows at an identical distance always come back in the same order, even though the index's own candidate order is not stable across runs.
216
+ - **Rows with a `NULL` embedding are never returned.** The vector index excludes them on its own.
217
+
218
+ **Both paths order by `(distance, id)`.** The determinism guarantee covers `exact: true` as well as the default, so the two paths return identical orderings for identical inputs and can be compared directly. This is a change in tie ordering for the exact path, which previously sorted by distance alone and left tied rows in whatever order the scan produced.
219
+
220
+ ### `candidates`
221
+
222
+ Controls how many rows the index returns for the exact re-rank. It must be an integer from `limit` through `MAX_SEARCH_CANDIDATES`; a value below `limit` is rejected rather than clamped, because it would silently truncate the result set. Invalid values throw before query embedding generation and before any database call.
223
+
224
+ ```ts
225
+ // Trade query cost for recall on a large corpus
226
+ const results = await search({ client, query, limit: 10, candidates: 200 });
227
+ ```
228
+
229
+ `candidates` has no effect when `exact` is `true` — that path scans every row — but it is **still validated**. `search({ exact: true, limit: 10, candidates: 5 })` throws, exactly as it would on the index path. Validity does not depend on which path a call happens to take.
230
+
231
+ Related exported constants:
232
+
233
+ - `DEFAULT_SEARCH_CANDIDATE_MULTIPLIER` (`4`): multiplier applied to `limit`
234
+ - `MIN_SEARCH_CANDIDATES` (`32`): floor for the derived default
235
+ - `MAX_SEARCH_CANDIDATES` (`1000`): ceiling for any explicit value. The derived default cannot reach it today, since `limit` tops out at `100`; the cap constrains explicit values.
236
+
237
+ ### `exact`
238
+
239
+ Set `exact: true` to bypass the index and score every row in the table.
240
+
241
+ ```ts
242
+ const results = await search({ client, query, exact: true });
243
+ ```
244
+
245
+ This is the guaranteed-exact path: it computes `vector_distance_cos` for every row with a non-`NULL` embedding, sorts by `(distance, id)`, and trims to `limit`. Cost grows linearly with table size, so it is intended for small corpora, correctness checks against the index path, and tables that have no vector index.
246
+
247
+ ### Missing Vector Index
248
+
249
+ If the target table has no `<tableName>_embedding_idx`, the default path throws an error naming the missing index and pointing at `createTable()` and `exact: true`. libSQL's own message for this case ("failed to parse vector index parameters") says nothing about a missing index, so it is preserved as the thrown error's `cause` rather than surfaced directly.
250
+
251
+ A deployment with no vector support at all is a separate case with its own message. `vector_top_k()` does not exist there, so no `CREATE INDEX` can help and the error points only at `exact: true`.
252
+
253
+ **Those two messages are the only ones rewritten.** Every other failure from the index path reaches the caller with its original message intact. In particular, a width mismatch between the query embedding and the `embedding` column surfaces as libSQL's own `vector index(search): dimensions are different: 384 != 4`, which names both widths and is the useful diagnostic for the dimension drift described in the [Migration and reindexing guide](./MIGRATIONS.md).
254
+
255
+ Search never falls back to the exact scan on its own. A silent fallback would turn a one-line schema fix into an invisible, permanent full-table scan.
256
+
257
+ ### Requirements
258
+
259
+ `vector_top_k()` and `libsql_vector_idx()` require a libSQL build with native vector support. The peer dependency is `@libsql/client ^0.15.0 || ^0.17.0`; this behavior is verified against `@libsql/client` `0.15.15` and `0.17.4` with a local `:memory:` database. On both, `vector_top_k()` returns the matched rowids in an `id` column, libSQL's missing-index wording ("failed to parse vector index parameters") is byte-identical, and so is the dimension-mismatch wording that is passed through unchanged — so the index path and the diagnostics above behave the same on either line. The two lines share one embedded engine (`libsql` `0.5.29`), which is why the on-disk format and index semantics do not differ between them.
260
+
261
+ Coverage differs by layer, and is worth stating plainly: the packaged build is smoke-tested against **both** peer arms on every release, which covers table and vector-index creation. The full test suite — including the byte-exact assertions on the messages above — runs against the dev-pinned client, currently `0.17.4`. The no-vector-support case is the one message not reproducible against a local build on either version; it is asserted from a synthesized error rather than a measured one. Remote Turso/libSQL servers must also support vector indexes — that is a property of the server, not the client, and no minimum server version is claimed here beyond that requirement. A deployment without it fails the default path with an error saying so and naming `exact: true` as the remedy. Use `exact: true` against any deployment where vector index support is unavailable or unverified.
262
+
200
263
  Result shape:
201
264
 
202
265
  ```ts
package/docs/INDEXING.md CHANGED
@@ -111,6 +111,40 @@ await indexContent({
111
111
 
112
112
  Both behaviors changed in a breaking way: partial failures used to be counted and reported, and an empty directory used to return zeros without clearing the table.
113
113
 
114
+ ## The Embedding Vector Index
115
+
116
+ `createTable()` creates `<tableName>_embedding_idx` alongside the table:
117
+
118
+ ```sql
119
+ CREATE INDEX IF NOT EXISTS "<tableName>_embedding_idx"
120
+ ON "<tableName>"(libsql_vector_idx(embedding))
121
+ ```
122
+
123
+ `search()` requires that index by default. It queries the index through `vector_top_k()` instead of scoring every row, so query cost no longer grows linearly with the size of the index.
124
+
125
+ That index is approximate. `search()` compensates by over-fetching candidates and re-ranking them exactly; see [`search(options)`](./API.md#searchoptions) for the recall and ordering semantics and for the `candidates` and `exact` options.
126
+
127
+ ### Tables Built Before The Index Existed
128
+
129
+ A table created by hand, or by a version of this package that predated the embedding index, has no `<tableName>_embedding_idx`. The default search path fails on such a table with an error naming the missing index — libSQL's own message for the case ("failed to parse vector index parameters") does not mention it.
130
+
131
+ `indexContent()` does not create the index; it only replaces rows. Two ways forward:
132
+
133
+ ```ts
134
+ // Preferred: createTable() is idempotent and adds only what is missing
135
+ await createTable(client, "articles", 384);
136
+ ```
137
+
138
+ ```sql
139
+ -- Or create the index directly against the existing table
140
+ CREATE INDEX IF NOT EXISTS "articles_embedding_idx"
141
+ ON "articles"(libsql_vector_idx(embedding));
142
+ ```
143
+
144
+ `createTable()` uses `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, so calling it against an existing table adds the missing index without touching rows. It still does not resize an existing vector column.
145
+
146
+ Until the index exists, pass `exact: true` to `search()` to keep queries working on the full-scan path.
147
+
114
148
  ## Quality Guidelines
115
149
 
116
150
  - include descriptive frontmatter titles
@@ -118,6 +152,7 @@ Both behaviors changed in a breaking way: partial failures used to be counted an
118
152
  - use the same provider and dimensions at index and query time
119
153
  - keep `maxLength` intentional if your content is very large
120
154
  - start with a smaller search `limit` and tune from real query behavior
155
+ - raise `candidates` if the approximate index path misses results the exact path finds; compare the two with `exact: true` on a fixed set of queries
121
156
 
122
157
  ## Build Integration
123
158
 
@@ -139,6 +174,6 @@ Many projects wire indexing into a dedicated script and call it before their sit
139
174
  ## Runtime Notes
140
175
 
141
176
  - local embeddings may download and cache a model on the first run
142
- - Node users need `@libsql/client` installed alongside the package
177
+ - Node users need `@libsql/client` installed alongside the package, at `^0.15.0 || ^0.17.0`; the packaged build is smoke-tested against both arms (`0.15.15` and `0.17.4`), which covers table and vector-index creation. `batch()` rollback behaves identically on both at the contract level, though its error text differs — see [Version differences](./TROUBLESHOOTING.md#libsqlclient-version-differences). Upgrading the client is not a prerequisite for upgrading this package. Deno/JSR users are not covered by that range and should pin the client themselves — see [Install](../README.md#install)
143
178
  - hosted providers send indexed or queried text to external services
144
179
  - the repository validates package build and `deno check`, but indexing still depends on filesystem access
@@ -23,6 +23,7 @@ References:
23
23
  - if dimensions stay the same but provider, model, endpoint, model revision, pooling, normalization, or input formatting changes, fully reindex anyway
24
24
  - never mix two embedding spaces in one table
25
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
+ - upgrading `@libsql/client` is not one of these migrations. The client moves bytes; it does not define the embedding space. Moving between the supported `^0.15.0` and `^0.17.0` lines leaves stored vectors, table widths, and the embedding index untouched and needs no reindex. This is checkable rather than merely inferred: `libsql`, the embedded native engine that owns the on-disk `F32_BLOB` format and the vector index, resolves to `0.5.29` under both client lines — the client bump does not move it. See [`@libsql/client` version differences](./TROUBLESHOOTING.md#libsqlclient-version-differences) for the two client-side behaviors that do change.
26
27
 
27
28
  In practice, this means:
28
29
 
@@ -75,6 +76,26 @@ const results = await search({
75
76
  });
76
77
  ```
77
78
 
79
+ ## Tables Without The Embedding Vector Index
80
+
81
+ `search()` queries the `<tableName>_embedding_idx` vector index by default rather than scanning the whole table. A table created by hand, or by a version of this package that predated that index, does not have it, and the default search path fails against such a table.
82
+
83
+ Re-running `createTable()` with the table's existing width is the fix. It is idempotent — `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` — so it adds the missing index without touching rows and without resizing the vector column:
84
+
85
+ ```ts
86
+ // Same name and same width as the existing table
87
+ await createTable(client, "articles_local_384", 384);
88
+ ```
89
+
90
+ Equivalently, in SQL:
91
+
92
+ ```sql
93
+ CREATE INDEX IF NOT EXISTS "articles_local_384_embedding_idx"
94
+ ON "articles_local_384"(libsql_vector_idx(embedding));
95
+ ```
96
+
97
+ Reindexing does not create the index; `indexContent()` only replaces rows. Any new table created by `createTable()` as part of a migration already has it, so this applies only to pre-existing tables you are carrying forward. Until the index exists, `search({ ..., exact: true })` keeps queries working on the exact full-scan path.
98
+
78
99
  ## Common Migration Paths
79
100
 
80
101
  | From | To | Why a rebuild is required | Recommended table move |
@@ -17,3 +17,62 @@ Common operational checks:
17
17
  or use a new table name before rebuilding
18
18
  - after upgrading an existing local 768-dimensional padded index, create or
19
19
  recreate a 384-dimensional table and fully re-index before querying it
20
+ - if `search()` reports that the `<tableName>_embedding_idx` vector index could
21
+ not be used, the table has no embedding vector index: re-run `createTable()`
22
+ with the table's existing name and width to add it without touching rows, or
23
+ pass `exact: true` to search on the full-scan path meanwhile. See
24
+ [Tables without the embedding vector index](./MIGRATIONS.md#tables-without-the-embedding-vector-index).
25
+ The underlying libSQL message, preserved as the error's `cause`, reads
26
+ "failed to parse vector index parameters" and does not mention the index
27
+ - if `search()` reports "no vector index support: vector_top_k() is
28
+ unavailable", the libSQL build or server itself has no vector support, so
29
+ creating an index will not help. Pass `exact: true` to search on the
30
+ full-scan path, or move to a deployment with vector support
31
+ - a libSQL error reading "dimensions are different: 384 != 4" is a different
32
+ problem and is passed through unchanged: the index exists, but the query
33
+ embedding's width does not match the `embedding` column's width. Creating an
34
+ index will not help and neither will `exact: true` — the exact path reports
35
+ the same root cause in different words, as
36
+ "vector_distance: vectors must have the same length: 4 != 384", with the
37
+ operands in the opposite order. Align the widths across
38
+ `createTable()`, `indexContent()`, and `search()`, and re-index if the stored
39
+ vectors are in the wrong space; see the
40
+ [Migration and reindexing guide](./MIGRATIONS.md)
41
+
42
+ ## `@libsql/client` Version Differences
43
+
44
+ The peer range is `^0.15.0 || ^0.17.0`, verified against `0.15.15` and `0.17.4`.
45
+ There is no `0.16.x` line upstream. Everything this package depends on behaves
46
+ the same on both — `vector_top_k()` returns rowids in an `id` column, the
47
+ missing-index message this library rewrites ("failed to parse vector index
48
+ parameters") and the dimension-mismatch message it passes through unchanged are
49
+ both byte-identical, and `batch(..., "write")` still rolls the whole rebuild back
50
+ on failure — so upgrading the client is optional and neither direction requires
51
+ re-indexing. (The third message, the no-vector-support case, is not reproducible
52
+ against a local build on either version; see
53
+ [Requirements](./API.md#requirements).)
54
+
55
+ Two client-side differences are visible to callers on `0.17.x`:
56
+
57
+ - the client no longer exports `./package.json`. Reading it by specifier, as in
58
+ `require("@libsql/client/package.json")`, throws
59
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`. Nothing in this package does that; if your own
60
+ tooling did, read the file through a direct `node_modules` path instead
61
+ - **constraint error codes lost the `_UNIQUE` suffix.** A duplicate slug that
62
+ reported `SQLITE_CONSTRAINT_UNIQUE` on `0.15.x` reports the broader
63
+ `SQLITE_CONSTRAINT` on `0.17.x`. This affects every caller on both query paths:
64
+
65
+ | | `0.15.15` | `0.17.4` |
66
+ | --- | --- | --- |
67
+ | `execute()` | `SQLITE_CONSTRAINT_UNIQUE: UNIQUE constraint failed: …` | `SQLITE_CONSTRAINT: UNIQUE constraint failed: …` |
68
+ | `batch(…, "write")` | `SQLITE_CONSTRAINT_UNIQUE: UNIQUE constraint failed: …` | `SQLITE_CONSTRAINT: SQLITE_CONSTRAINT: UNIQUE constraint failed: …` |
69
+
70
+ Note that the prefix is additionally **doubled on the `batch()` path only** —
71
+ that is the path `indexContent()` uses, so it is what surfaces as the `cause`
72
+ of an `IndexingError` with `phase: "replace"`. Your own `execute()` calls show
73
+ the single prefix. Both are cosmetic: the rollback and the `IndexingError`
74
+ contract are unchanged.
75
+
76
+ A log matcher or alert rule keyed on `SQLITE_CONSTRAINT_UNIQUE` will stop
77
+ matching after the upgrade, on either path. Match on `UNIQUE constraint failed`
78
+ instead — it is the one substring stable across all four cells above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.34.5",
@@ -60,14 +60,14 @@
60
60
  "node": ">=22.12.0"
61
61
  },
62
62
  "peerDependencies": {
63
- "@libsql/client": "^0.15.0"
63
+ "@libsql/client": "^0.15.0 || ^0.17.0"
64
64
  },
65
65
  "dependencies": {
66
66
  "@huggingface/transformers": "4.2.0",
67
67
  "gray-matter": "^4.0.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@libsql/client": "^0.15.15",
70
+ "@libsql/client": "^0.17.4",
71
71
  "@rollup/plugin-commonjs": "^29.0.3",
72
72
  "@rollup/plugin-node-resolve": "^16.0.3",
73
73
  "@types/node": "^24.13.3",