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 +21 -1
- package/dist/index.cjs +103 -17
- package/dist/index.d.ts +49 -2
- package/dist/index.esm.js +101 -18
- package/docs/API.md +61 -0
- package/docs/INDEXING.md +35 -0
- package/docs/MIGRATIONS.md +20 -0
- package/docs/TROUBLESHOOTING.md +21 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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:
|
|
1192
|
+
args: { queryVector, resultLimit }
|
|
1130
1193
|
});
|
|
1131
|
-
|
|
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
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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:
|
|
1190
|
+
args: { queryVector, resultLimit }
|
|
1128
1191
|
});
|
|
1129
|
-
|
|
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
|
|
package/docs/MIGRATIONS.md
CHANGED
|
@@ -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 |
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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)
|