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