libsql-search 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -74,13 +74,33 @@ console.log(results.map((result) => ({
74
74
 
75
75
  Important behavior:
76
76
 
77
- - Call `createTable()` before indexing or searching.
77
+ - Call `createTable()` before indexing or searching. It creates the `<tableName>_embedding_idx` vector index that `search()` needs.
78
78
  - Keep table width, provider, and dimensions aligned across create/index/query.
79
79
  - `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
80
  - `indexContent()` throws `IndexingError` when a file fails; pass `failurePolicy: "skip"` to rebuild from the remaining files.
81
81
  - `indexContent()` throws `IndexingError` when no source files are found; pass `allowEmptyIndex: true` to intentionally empty the index.
82
82
  - Hosted providers send indexed and queried text to external services and may incur provider charges.
83
83
 
84
+ ## Search Accuracy And Performance
85
+
86
+ `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.
87
+
88
+ 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.
89
+
90
+ Two options control the trade-off:
91
+
92
+ ```ts
93
+ // Widen the index probe to raise recall (default: max(limit * 4, 32))
94
+ await search({ client, query, limit: 10, candidates: 200 });
95
+
96
+ // Bypass the index entirely: exact, but linear in table size
97
+ await search({ client, query, exact: true });
98
+ ```
99
+
100
+ `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.
101
+
102
+ 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`, verified against `0.15.15`; 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.
103
+
84
104
  ## Providers
85
105
 
86
106
  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,65 @@ 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`; this behavior is verified against `@libsql/client` `0.15.15` with a local `:memory:` database. 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.
260
+
200
261
  Result shape:
201
262
 
202
263
  ```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
 
@@ -75,6 +75,26 @@ const results = await search({
75
75
  });
76
76
  ```
77
77
 
78
+ ## Tables Without The Embedding Vector Index
79
+
80
+ `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.
81
+
82
+ 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:
83
+
84
+ ```ts
85
+ // Same name and same width as the existing table
86
+ await createTable(client, "articles_local_384", 384);
87
+ ```
88
+
89
+ Equivalently, in SQL:
90
+
91
+ ```sql
92
+ CREATE INDEX IF NOT EXISTS "articles_local_384_embedding_idx"
93
+ ON "articles_local_384"(libsql_vector_idx(embedding));
94
+ ```
95
+
96
+ 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.
97
+
78
98
  ## Common Migration Paths
79
99
 
80
100
  | From | To | Why a rebuild is required | Recommended table move |
@@ -17,3 +17,24 @@ 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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",