libsql-search 0.7.1 → 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,11 +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
- - `indexContent()` clears existing rows before rebuilding and is not transactional.
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
+ - `indexContent()` throws `IndexingError` when a file fails; pass `failurePolicy: "skip"` to rebuild from the remaining files.
81
+ - `indexContent()` throws `IndexingError` when no source files are found; pass `allowEmptyIndex: true` to intentionally empty the index.
80
82
  - Hosted providers send indexed and queried text to external services and may incur provider charges.
81
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
+
82
104
  ## Providers
83
105
 
84
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");
@@ -813,6 +833,16 @@ function normalizeVectorDimensions(dimensions) {
813
833
  return dimensions;
814
834
  }
815
835
 
836
+ class IndexingError extends Error {
837
+ phase;
838
+ failures;
839
+ constructor(message, phase, failures = [], options) {
840
+ super(message, options);
841
+ this.name = "IndexingError";
842
+ this.phase = phase;
843
+ this.failures = failures;
844
+ }
845
+ }
816
846
  async function indexContent(options) {
817
847
  const {
818
848
  client,
@@ -821,32 +851,92 @@ async function indexContent(options) {
821
851
  fileExtensions = [".md", ".markdown"],
822
852
  exclude = ["node_modules", ".git", "dist", "build"],
823
853
  tableName = "articles",
824
- onProgress
854
+ onProgress,
855
+ failurePolicy = "abort",
856
+ allowEmptyIndex = false
825
857
  } = options;
826
858
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
827
- const files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
828
- if (files.length === 0) {
829
- console.warn(`No files found in ${contentPath}`);
830
- return { success: 0, failed: 0, total: 0 };
831
- }
832
- await client.execute(`DELETE FROM ${quotedTableName}`);
833
- let success = 0;
834
- let failed = 0;
859
+ let files;
860
+ try {
861
+ files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
862
+ } catch (error) {
863
+ throw new IndexingError(
864
+ `Failed to scan ${contentPath} for source files. The existing index was left unchanged.`,
865
+ "build",
866
+ [],
867
+ { cause: toError(error) }
868
+ );
869
+ }
870
+ files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
871
+ if (files.length === 0 && !allowEmptyIndex) {
872
+ throw new IndexingError(
873
+ `No source files found in ${contentPath}. The existing index was left unchanged. Pass allowEmptyIndex: true to intentionally empty the index.`,
874
+ "build"
875
+ );
876
+ }
877
+ const documents = [];
878
+ const failures = [];
879
+ const slugOwners = /* @__PURE__ */ new Map();
835
880
  for (let i = 0; i < files.length; i++) {
836
881
  const file = files[i];
837
882
  if (onProgress) {
838
883
  onProgress(i + 1, files.length, file.relativePath);
839
884
  }
840
- try {
841
- const document = await processFile(file, embeddingOptions);
842
- await insertDocument(client, document, quotedTableName);
843
- success++;
844
- } catch (error) {
845
- console.error(`Failed to index ${file.relativePath}:`, error);
846
- failed++;
885
+ const outcome = await buildDocument(file, embeddingOptions);
886
+ let failure;
887
+ if (outcome.ok) {
888
+ const owner = slugOwners.get(outcome.document.slug);
889
+ if (owner === void 0) {
890
+ slugOwners.set(outcome.document.slug, file.relativePath);
891
+ documents.push(outcome.document);
892
+ } else {
893
+ failure = {
894
+ file: file.relativePath,
895
+ stage: "parse",
896
+ error: new Error(
897
+ `Duplicate slug "${outcome.document.slug}": ${file.relativePath} collides with ${owner}`
898
+ )
899
+ };
900
+ }
901
+ } else {
902
+ failure = {
903
+ file: file.relativePath,
904
+ stage: outcome.stage,
905
+ error: outcome.error
906
+ };
907
+ }
908
+ if (failure === void 0) {
909
+ continue;
847
910
  }
911
+ if (failurePolicy === "abort") {
912
+ throw new IndexingError(
913
+ `Failed to ${failure.stage} ${file.relativePath}: ${failure.error.message}. The existing index was left unchanged. Pass failurePolicy: 'skip' to rebuild from the remaining files.`,
914
+ "build",
915
+ [failure],
916
+ { cause: failure.error }
917
+ );
918
+ }
919
+ console.error(`Skipping ${file.relativePath} (${failure.stage} failed):`, failure.error);
920
+ failures.push(failure);
921
+ }
922
+ if (files.length > 0 && documents.length === 0) {
923
+ throw new IndexingError(
924
+ `All ${files.length} source file(s) in ${contentPath} failed to index. The existing index was left unchanged.`,
925
+ "build",
926
+ failures,
927
+ { cause: failures[0].error }
928
+ );
848
929
  }
849
- return { success, failed, total: files.length };
930
+ const success = documents.length;
931
+ await replaceIndex(client, quotedTableName, documents, failures);
932
+ return {
933
+ success,
934
+ failed: failures.length,
935
+ total: files.length,
936
+ replaced: true,
937
+ partial: failures.length > 0,
938
+ failures
939
+ };
850
940
  }
851
941
  async function findFiles(dir, baseDir, extensions, exclude) {
852
942
  const files = [];
@@ -870,34 +960,113 @@ async function findFiles(dir, baseDir, extensions, exclude) {
870
960
  }
871
961
  return files;
872
962
  }
873
- async function processFile(file, embeddingOptions) {
874
- const content = await promises.readFile(file.fullPath, "utf-8");
875
- const { data: frontMatter, content: markdown } = matter(content);
963
+ async function buildDocument(file, embeddingOptions) {
964
+ let raw;
965
+ try {
966
+ raw = await promises.readFile(file.fullPath, "utf-8");
967
+ } catch (error) {
968
+ return { ok: false, stage: "read", error: toError(error) };
969
+ }
970
+ let parsed;
971
+ try {
972
+ parsed = parseFile(file, raw);
973
+ } catch (error) {
974
+ return { ok: false, stage: "parse", error: toError(error) };
975
+ }
976
+ let embedding;
977
+ try {
978
+ embedding = await generateEmbedding(parsed.embeddingText, {
979
+ ...embeddingOptions,
980
+ intent: embeddingOptions.intent ?? "document"
981
+ });
982
+ } catch (error) {
983
+ return { ok: false, stage: "embed", error: toError(error) };
984
+ }
985
+ return {
986
+ ok: true,
987
+ document: {
988
+ slug: parsed.slug,
989
+ title: parsed.title,
990
+ content: parsed.content,
991
+ folder: file.folder,
992
+ tags: parsed.tags,
993
+ serializedTags: parsed.serializedTags,
994
+ embedding,
995
+ metadata: parsed.metadata
996
+ }
997
+ };
998
+ }
999
+ function parseFile(file, raw) {
1000
+ const { data: frontMatter, content: markdown } = matter(raw);
876
1001
  const slug = file.relativePath.replace(/\.(md|markdown)$/, "").replace(/\\/g, "/");
877
- const title = frontMatter.title || file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
1002
+ const title = resolveTitle(file, frontMatter.title);
878
1003
  const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
1004
+ const serializedTags = JSON.stringify(tags);
879
1005
  const embeddingText = prepareTextForEmbedding({
880
1006
  title,
881
1007
  description: frontMatter.description,
882
1008
  content: markdown,
883
1009
  tags
884
1010
  });
885
- const embedding = await generateEmbedding(embeddingText, {
886
- ...embeddingOptions,
887
- intent: embeddingOptions.intent ?? "document"
888
- });
889
1011
  return {
890
1012
  slug,
891
1013
  title,
892
1014
  content: markdown,
893
- folder: file.folder,
894
1015
  tags,
895
- embedding,
1016
+ serializedTags,
1017
+ embeddingText,
896
1018
  metadata: frontMatter
897
1019
  };
898
1020
  }
899
- async function insertDocument(client, document, quotedTableName) {
900
- await client.execute({
1021
+ function resolveTitle(file, rawTitle) {
1022
+ if (!rawTitle) {
1023
+ return fallbackTitle(file);
1024
+ }
1025
+ if (typeof rawTitle === "string") {
1026
+ return rawTitle;
1027
+ }
1028
+ if (typeof rawTitle === "number" || typeof rawTitle === "bigint" || typeof rawTitle === "boolean") {
1029
+ return String(rawTitle);
1030
+ }
1031
+ if (rawTitle instanceof Date) {
1032
+ return rawTitle.toISOString();
1033
+ }
1034
+ throw new Error(
1035
+ `Unsupported frontmatter title of type ${describeType(rawTitle)}: expected a string`
1036
+ );
1037
+ }
1038
+ function fallbackTitle(file) {
1039
+ return file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
1040
+ }
1041
+ function describeType(value) {
1042
+ return Array.isArray(value) ? "array" : typeof value;
1043
+ }
1044
+ async function replaceIndex(client, quotedTableName, documents, failures) {
1045
+ const statements = [`DELETE FROM ${quotedTableName}`];
1046
+ try {
1047
+ for (let i = 0; i < documents.length; i++) {
1048
+ const document = documents[i];
1049
+ if (document !== void 0) {
1050
+ statements.push(createInsertStatement(document, quotedTableName));
1051
+ documents[i] = void 0;
1052
+ }
1053
+ }
1054
+ } finally {
1055
+ documents.length = 0;
1056
+ }
1057
+ try {
1058
+ await client.batch(statements, "write");
1059
+ } catch (error) {
1060
+ throw new IndexingError(
1061
+ `Failed to replace the contents of ${quotedTableName}. The transaction was rolled back and the existing index was left unchanged.`,
1062
+ "replace",
1063
+ failures,
1064
+ { cause: toError(error) }
1065
+ );
1066
+ }
1067
+ }
1068
+ function createInsertStatement(document, quotedTableName) {
1069
+ return {
901
1070
  sql: `INSERT INTO ${quotedTableName}
902
1071
  (slug, title, content, folder, tags, embedding, created_at, updated_at)
903
1072
  VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
@@ -906,10 +1075,13 @@ async function insertDocument(client, document, quotedTableName) {
906
1075
  document.title,
907
1076
  document.content,
908
1077
  document.folder,
909
- JSON.stringify(document.tags),
1078
+ document.serializedTags,
910
1079
  JSON.stringify(document.embedding)
911
1080
  ]
912
- });
1081
+ };
1082
+ }
1083
+ function toError(error) {
1084
+ return error instanceof Error ? error : new Error(String(error));
913
1085
  }
914
1086
  async function createTable(client, tableName = "articles", dimensions = 384) {
915
1087
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -944,39 +1116,102 @@ async function createTable(client, tableName = "articles", dimensions = 384) {
944
1116
  `);
945
1117
  }
946
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
+ ];
947
1126
  async function search(options) {
948
1127
  const {
949
1128
  client,
950
1129
  query,
951
1130
  limit = 10,
952
1131
  tableName = "articles",
953
- embeddingOptions = {}
1132
+ embeddingOptions = {},
1133
+ candidates,
1134
+ exact = false
954
1135
  } = options;
955
1136
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
956
1137
  const resultLimit = normalizeSearchLimit(limit);
1138
+ const embeddingIndexName = validateSqlIdentifier(
1139
+ `${tableName}_embedding_idx`,
1140
+ "embedding index name"
1141
+ );
1142
+ const candidateCount = normalizeSearchCandidates(candidates, resultLimit);
957
1143
  const queryEmbedding = await generateEmbedding(query, {
958
1144
  ...embeddingOptions,
959
1145
  intent: embeddingOptions.intent ?? "query"
960
1146
  });
961
- 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({
962
1183
  sql: `
963
1184
  SELECT
964
- id,
965
- slug,
966
- title,
967
- content,
968
- folder,
969
- tags,
970
- created_at,
971
- vector_distance_cos(embedding, vector(?)) as distance
972
- FROM ${quotedTableName}
973
- WHERE embedding IS NOT NULL
974
- ORDER BY distance
975
- 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
976
1191
  `,
977
- args: [JSON.stringify(queryEmbedding), resultLimit]
1192
+ args: { queryVector, resultLimit }
978
1193
  });
979
- 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 {
980
1215
  id: row.id,
981
1216
  slug: row.slug,
982
1217
  title: row.title,
@@ -985,7 +1220,7 @@ async function search(options) {
985
1220
  tags: JSON.parse(row.tags || "[]"),
986
1221
  distance: row.distance,
987
1222
  created_at: row.created_at
988
- }));
1223
+ };
989
1224
  }
990
1225
  async function getAllArticles(client, tableName = "articles") {
991
1226
  const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
@@ -1059,6 +1294,10 @@ async function getFolders(client, tableName = "articles") {
1059
1294
  return results.rows.map((row) => row.folder);
1060
1295
  }
1061
1296
 
1297
+ exports.DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = DEFAULT_SEARCH_CANDIDATE_MULTIPLIER;
1298
+ exports.IndexingError = IndexingError;
1299
+ exports.MAX_SEARCH_CANDIDATES = MAX_SEARCH_CANDIDATES;
1300
+ exports.MIN_SEARCH_CANDIDATES = MIN_SEARCH_CANDIDATES;
1062
1301
  exports.createEmbeddingProvider = createEmbeddingProvider;
1063
1302
  exports.createTable = createTable;
1064
1303
  exports.generateEmbedding = generateEmbedding;
package/dist/index.d.ts CHANGED
@@ -82,6 +82,28 @@ declare function prepareTextForEmbedding(fields: {
82
82
  * Content indexer for markdown and other formats
83
83
  */
84
84
 
85
+ /**
86
+ * How build-phase failures are handled.
87
+ *
88
+ * - `abort` (default) rejects the whole rebuild on the first failure
89
+ * - `skip` drops the failing file and rebuilds from the survivors
90
+ */
91
+ type IndexFailurePolicy = 'abort' | 'skip';
92
+ /** The step that failed while turning a file into an indexable document. */
93
+ type IndexFailureStage = 'read' | 'parse' | 'embed';
94
+ /**
95
+ * The phase an {@link IndexingError} was raised in.
96
+ *
97
+ * - `build` means no database state was touched
98
+ * - `replace` means the replacement transaction failed and was rolled back
99
+ */
100
+ type IndexingErrorPhase = 'build' | 'replace';
101
+ interface IndexFailure {
102
+ /** Path of the file relative to `contentPath`. */
103
+ file: string;
104
+ stage: IndexFailureStage;
105
+ error: Error;
106
+ }
85
107
  interface IndexerOptions {
86
108
  client: Client;
87
109
  contentPath: string;
@@ -90,6 +112,23 @@ interface IndexerOptions {
90
112
  exclude?: string[];
91
113
  tableName?: string;
92
114
  onProgress?: (current: number, total: number, file: string) => void;
115
+ /** Defaults to `abort`. */
116
+ failurePolicy?: IndexFailurePolicy;
117
+ /** Allow an empty source directory to empty the index. Defaults to `false`. */
118
+ allowEmptyIndex?: boolean;
119
+ }
120
+ interface IndexResult {
121
+ /** Documents written to the table. */
122
+ success: number;
123
+ /** Files that could not be indexed. */
124
+ failed: number;
125
+ /** Files discovered on disk. */
126
+ total: number;
127
+ /** Whether table contents were replaced by this call. */
128
+ replaced: boolean;
129
+ /** Replaced, but some files were skipped. */
130
+ partial: boolean;
131
+ failures: IndexFailure[];
93
132
  }
94
133
  interface IndexedDocument {
95
134
  slug: string;
@@ -101,18 +140,48 @@ interface IndexedDocument {
101
140
  metadata?: Record<string, any>;
102
141
  }
103
142
  /**
104
- * Index markdown content from a directory
143
+ * Raised when a rebuild cannot complete. The previously indexed rows are always
144
+ * left exactly as they were.
105
145
  */
106
- declare function indexContent(options: IndexerOptions): Promise<{
107
- success: number;
108
- failed: number;
109
- total: number;
110
- }>;
146
+ declare class IndexingError extends Error {
147
+ readonly phase: IndexingErrorPhase;
148
+ readonly failures: IndexFailure[];
149
+ constructor(message: string, phase: IndexingErrorPhase, failures?: IndexFailure[], options?: ErrorOptions);
150
+ }
151
+ /**
152
+ * Index markdown content from a directory.
153
+ *
154
+ * Every document is read, parsed, and embedded in memory before any database
155
+ * state changes. The table is then replaced inside a single write transaction,
156
+ * so a failure at any point leaves the previous index intact.
157
+ */
158
+ declare function indexContent(options: IndexerOptions): Promise<IndexResult>;
111
159
  /**
112
160
  * Create the articles table if it doesn't exist
113
161
  */
114
162
  declare function createTable(client: Client, tableName?: string, dimensions?: number): Promise<void>;
115
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
+
116
185
  /**
117
186
  * Vector search functionality
118
187
  */
@@ -123,6 +192,22 @@ interface SearchOptions {
123
192
  limit?: number;
124
193
  tableName?: string;
125
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;
126
211
  }
127
212
  interface SearchResult {
128
213
  id: number;
@@ -135,7 +220,17 @@ interface SearchResult {
135
220
  created_at: string;
136
221
  }
137
222
  /**
138
- * 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.
139
234
  */
140
235
  declare function search(options: SearchOptions): Promise<SearchResult[]>;
141
236
  /**
@@ -178,5 +273,5 @@ declare function getArticlesByFolder(client: Client, folder: string, tableName?:
178
273
  */
179
274
  declare function getFolders(client: Client, tableName?: string): Promise<string[]>;
180
275
 
181
- export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
182
- export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexedDocument, IndexerOptions, SearchOptions, SearchResult };
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 };
277
+ export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexFailure, IndexFailurePolicy, IndexFailureStage, IndexResult, IndexedDocument, IndexerOptions, IndexingErrorPhase, SearchOptions, SearchResult };