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/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()` clears the target table before rebuilding:
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
- That keeps the implementation simple, but it also means:
31
+ The rebuild runs in two phases:
32
32
 
33
- - failed rebuilds can leave the table partially repopulated
34
- - provider or dimension changes should use a parallel table migration
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
 
@@ -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()` clears rows first and is not transactional
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. Breaking changes bump major, `feat:` bumps minor, and all other
23
- eligible commits bump patch.
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
@@ -17,3 +17,24 @@ Common operational checks:
17
17
  or use a new table name before rebuilding
18
18
  - after upgrading an existing local 768-dimensional padded index, create or
19
19
  recreate a 384-dimensional table and fully re-index before querying it
20
+ - if `search()` reports that the `<tableName>_embedding_idx` vector index could
21
+ not be used, the table has no embedding vector index: re-run `createTable()`
22
+ with the table's existing name and width to add it without touching rows, or
23
+ pass `exact: true` to search on the full-scan path meanwhile. See
24
+ [Tables without the embedding vector index](./MIGRATIONS.md#tables-without-the-embedding-vector-index).
25
+ The underlying libSQL message, preserved as the error's `cause`, reads
26
+ "failed to parse vector index parameters" and does not mention the index
27
+ - if `search()` reports "no vector index support: vector_top_k() is
28
+ unavailable", the libSQL build or server itself has no vector support, so
29
+ creating an index will not help. Pass `exact: true` to search on the
30
+ full-scan path, or move to a deployment with vector support
31
+ - a libSQL error reading "dimensions are different: 384 != 4" is a different
32
+ problem and is passed through unchanged: the index exists, but the query
33
+ embedding's width does not match the `embedding` column's width. Creating an
34
+ index will not help and neither will `exact: true` — the exact path reports
35
+ the same root cause in different words, as
36
+ "vector_distance: vectors must have the same length: 4 != 384", with the
37
+ operands in the opposite order. Align the widths across
38
+ `createTable()`, `indexContent()`, and `search()`, and re-index if the stored
39
+ vectors are in the wrong space; see the
40
+ [Migration and reindexing guide](./MIGRATIONS.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.34.5",