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 +24 -2
- package/dist/index.cjs +287 -48
- package/dist/index.d.ts +104 -9
- package/dist/index.esm.js +284 -49
- package/docs/API.md +130 -6
- package/docs/INDEXING.md +114 -4
- package/docs/MIGRATIONS.md +23 -1
- package/docs/RELEASING.md +11 -3
- package/docs/TROUBLESHOOTING.md +21 -0
- package/package.json +1 -1
package/docs/INDEXING.md
CHANGED
|
@@ -15,7 +15,7 @@ The slug is derived from the file path relative to `contentPath`.
|
|
|
15
15
|
|
|
16
16
|
## Rebuild Behavior
|
|
17
17
|
|
|
18
|
-
`indexContent()`
|
|
18
|
+
`indexContent()` replaces the whole target table:
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
await indexContent({
|
|
@@ -28,14 +28,123 @@ await indexContent({
|
|
|
28
28
|
});
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
The rebuild runs in two phases:
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
1. build: every file is read, parsed, and embedded in memory, touching no database state
|
|
34
|
+
2. replace: the delete and all inserts run in a single write transaction
|
|
35
|
+
|
|
36
|
+
That means:
|
|
37
|
+
|
|
38
|
+
- a failed rebuild leaves the previously indexed rows exactly as they were
|
|
39
|
+
- provider or dimension changes should still use a parallel table migration
|
|
35
40
|
- `createTable()` does not resize an existing vector column
|
|
36
41
|
|
|
42
|
+
Files are discovered and indexed in sorted path order, so a rebuild is deterministic.
|
|
43
|
+
|
|
37
44
|
If provider, dimensions, model, endpoint, or embedding-space assumptions change, fully reindex into a new table. See the canonical [Migration and reindexing guide](./MIGRATIONS.md).
|
|
38
45
|
|
|
46
|
+
### Costs Of The Two-Phase Rebuild
|
|
47
|
+
|
|
48
|
+
Atomicity is not free, and both costs scale with corpus size:
|
|
49
|
+
|
|
50
|
+
- **Peak memory holds the whole corpus.** The build phase keeps every document in memory: content, frontmatter, and one embedding array per document. The replace phase then builds insert statements including a JSON copy of each embedding, roughly 5-8 KB per document at 384 dimensions and considerably more at 3072. Documents are released as their statements are built, but peak usage is still proportional to the entire corpus rather than to one file.
|
|
51
|
+
- **Remote clients send one request.** Against Turso or any remote client, the delete and every insert travel as a single batch. There is no chunking fallback, because splitting the batch would give up the atomicity this design exists to provide. A corpus large enough to exceed a remote request-size limit fails as an opaque `phase: "replace"` error.
|
|
52
|
+
|
|
53
|
+
For very large corpora, index into a parallel table and switch reads over once it validates, rather than rebuilding a live table in place. See the [Migration and reindexing guide](./MIGRATIONS.md).
|
|
54
|
+
|
|
55
|
+
## Content Requirements
|
|
56
|
+
|
|
57
|
+
Two authoring mistakes fail a file at the `parse` stage rather than corrupting the rebuild:
|
|
58
|
+
|
|
59
|
+
- **Frontmatter `title` must be a scalar.** Strings, numbers, booleans, and dates are accepted; dates are stored as ISO strings. A structured title such as a YAML list fails the file. A missing or empty title still falls back to the filename.
|
|
60
|
+
- **Slugs must be unique.** The slug comes from the path with the extension removed, so `foo.md` and `foo.markdown` collide. Files are processed in sorted path order and the first file to claim a slug keeps it, so `foo.markdown` wins and `foo.md` is reported as the failure.
|
|
61
|
+
|
|
62
|
+
Both are governed by `failurePolicy` like any other build failure, so they abort by default and are skippable.
|
|
63
|
+
|
|
64
|
+
## Failure Handling
|
|
65
|
+
|
|
66
|
+
`indexContent()` throws `IndexingError` instead of reporting a partially applied rebuild. The error carries `phase` (`"build"` or `"replace"`), a `failures` array, and the underlying error as `cause`.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { indexContent, IndexingError } from "libsql-search";
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await indexContent({ client, contentPath: "./content" });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof IndexingError) {
|
|
75
|
+
for (const failure of error.failures) {
|
|
76
|
+
console.error(`${failure.file} failed during ${failure.stage}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
By default one bad file aborts the whole rebuild. To index everything that can be indexed, opt into `failurePolicy: "skip"`:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const result = await indexContent({
|
|
88
|
+
client,
|
|
89
|
+
contentPath: "./content",
|
|
90
|
+
failurePolicy: "skip",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (result.partial) {
|
|
94
|
+
console.warn(`Indexed ${result.success} of ${result.total} files`);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Skipped rebuilds still replace the table, so treat `partial: true` as a build warning rather than a clean rebuild. If every discovered file fails, the rebuild throws rather than trading a valid index for an empty one.
|
|
99
|
+
|
|
100
|
+
## Empty Source Directories
|
|
101
|
+
|
|
102
|
+
An empty source directory throws by default, because silently leaving stale rows in place serves search traffic from content that no longer exists. Emptying an index has to be intentional:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
await indexContent({
|
|
106
|
+
client,
|
|
107
|
+
contentPath: "./content",
|
|
108
|
+
allowEmptyIndex: true,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
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
|
+
|
|
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
|
+
|
|
39
148
|
## Quality Guidelines
|
|
40
149
|
|
|
41
150
|
- include descriptive frontmatter titles
|
|
@@ -43,6 +152,7 @@ If provider, dimensions, model, endpoint, or embedding-space assumptions change,
|
|
|
43
152
|
- use the same provider and dimensions at index and query time
|
|
44
153
|
- keep `maxLength` intentional if your content is very large
|
|
45
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
|
|
46
156
|
|
|
47
157
|
## Build Integration
|
|
48
158
|
|
package/docs/MIGRATIONS.md
CHANGED
|
@@ -22,7 +22,7 @@ References:
|
|
|
22
22
|
- if dimensions change, create a new table or recreate the old table, then fully re-embed
|
|
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
|
-
- prefer a parallel table migration because `indexContent()`
|
|
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
26
|
|
|
27
27
|
In practice, this means:
|
|
28
28
|
|
|
@@ -39,6 +39,8 @@ In practice, this means:
|
|
|
39
39
|
5. Switch application reads and writes to the new table.
|
|
40
40
|
6. Retire the old table in a separate cleanup step.
|
|
41
41
|
|
|
42
|
+
Step 3 is all or nothing. `indexContent()` throws `IndexingError` and leaves the target table untouched when a file or the replacement transaction fails, so a failed migration step can be retried without cleanup. See [Indexing and operational behavior](./INDEXING.md) for `failurePolicy` and `allowEmptyIndex`.
|
|
43
|
+
|
|
42
44
|
Example:
|
|
43
45
|
|
|
44
46
|
```ts
|
|
@@ -73,6 +75,26 @@ const results = await search({
|
|
|
73
75
|
});
|
|
74
76
|
```
|
|
75
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
|
+
|
|
76
98
|
## Common Migration Paths
|
|
77
99
|
|
|
78
100
|
| From | To | Why a rebuild is required | Recommended table move |
|
package/docs/RELEASING.md
CHANGED
|
@@ -19,11 +19,19 @@ and JSR, then creates GitHub Release notes after both registries succeed.
|
|
|
19
19
|
`.github/**` do not count as release-eligible and do not affect the bump. If
|
|
20
20
|
the newest `main` commit is docs-only but an earlier untagged code or README
|
|
21
21
|
commit is still pending, that newest run releases the accumulated eligible
|
|
22
|
-
changes.
|
|
23
|
-
|
|
22
|
+
changes. `feat:` bumps minor and all other eligible commits bump patch.
|
|
23
|
+
Breaking changes (a `subject!:` prefix or a `BREAKING CHANGE:` footer) bump
|
|
24
|
+
major only once the package is `1.0.0` or higher. While the package is still
|
|
25
|
+
on the `0.x` line a breaking change bumps the minor instead, so an unattended
|
|
26
|
+
`fix!:` cannot auto-promote the package to `1.0.0`. Promoting off `0.x` is
|
|
27
|
+
deliberate and manual: see step 5.
|
|
24
28
|
5. `package.json`, `jsr.json`, and `deno.json` are synchronized to the chosen
|
|
25
29
|
version. If they already match the chosen version, no release commit is
|
|
26
|
-
created.
|
|
30
|
+
created. A manifest version that is already ahead of the latest tag wins over
|
|
31
|
+
the computed bump; this is the supported escape hatch for a deliberate
|
|
32
|
+
version jump, including promoting off the `0.x` line. Set all three manifests
|
|
33
|
+
to `1.0.0` on a reviewed PR and the next qualifying release publishes
|
|
34
|
+
`v1.0.0`.
|
|
27
35
|
6. The workflow validates the candidate, creates an annotated tag, and atomically
|
|
28
36
|
pushes the release commit plus tag.
|
|
29
37
|
7. npm publishes `libsql-search` through trusted publishing OIDC with the GitHub
|
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)
|