libsql-search exports:
createTableindexContentsearchgetAllArticlesgetArticleBySluggetArticlesByFoldergetFoldersgenerateEmbeddinggenerateEmbeddingscreateEmbeddingProvidergetEmbeddingProviderMetadatavalidateEmbeddingBatchpadEmbeddingprepareTextForEmbeddingIndexingErrorDEFAULT_SEARCH_CANDIDATE_MULTIPLIERMIN_SEARCH_CANDIDATESMAX_SEARCH_CANDIDATES
It also exports these types:
EmbeddingProviderEmbeddingIntentEmbeddingBatchModeEmbeddingBatchBehaviorEmbeddingProviderMetadataEmbeddingRequestOptionsEmbeddingProviderClientEmbeddingBatchResultEmbeddingBatchItemResultEmbeddingBatchItemEmbeddingOptionsIndexerOptionsIndexedDocumentIndexResultIndexFailureIndexFailurePolicyIndexFailureStageIndexingErrorPhaseSearchOptionsSearchResult
- Provider matrix and credential rules
- Migration and reindexing guide
- Indexing and operational behavior
- Testing guidance
- Turso Database backend
Every function that talks to a database takes a client. It accepts either:
- a
@libsql/clientClient— the default, and the only backend the main entry point references, or - an adapter from a backend entry point, currently
tursoAdapter()fromlibsql-search/turso
IndexerOptions["client"] and SearchOptions["client"] are typed as
Client | DatabaseAdapter, so existing Client-typed code needs no change. The
main entry point exports no new symbol for this and never imports a backend
package other than @libsql/client.
The adapter carries a capability flag that changes two behaviors on a backend
without an approximate-nearest-neighbor vector index: createTable() skips the
vector index, and search() uses the exact path automatically. Both are
described in the Turso Database backend guide, which is currently
the only backend where they apply.
Creates the search table and supporting indexes.
await createTable(client);On a backend with no vector index support, the <tableName>_embedding_idx
vector index is skipped; the table, the folder index, and the slug index are
still created. On @libsql/client it is never skipped.
Defaults:
tableName:"articles"dimensions:384
tableName must be an ASCII SQLite identifier matching [A-Za-z_][A-Za-z0-9_]*. Valid identifiers are quoted internally, so reserved words such as "select" are safe to use. dimensions must be a positive integer.
The created schema includes:
idprimary keyslugtitlecontentfoldertagsembedding F32_BLOB(dimensions)created_atupdated_at
createTable() uses CREATE TABLE IF NOT EXISTS, so it does not resize an existing vector column. See Migration and reindexing guide before changing widths or providers.
Indexes Markdown files from a directory on disk.
interface IndexerOptions {
client: Client | DatabaseAdapter;
contentPath: string;
embeddingOptions: EmbeddingOptions;
fileExtensions?: string[];
exclude?: string[];
tableName?: string;
onProgress?: (current: number, total: number, file: string) => void;
failurePolicy?: "abort" | "skip";
allowEmptyIndex?: boolean;
}Defaults:
fileExtensions: [".md", ".markdown"]exclude: ["node_modules", ".git", "dist", "build"]tableName:"articles"failurePolicy:"abort"allowEmptyIndex:false
Return shape:
interface IndexResult {
success: number; // documents written
failed: number; // files that could not be indexed
total: number; // files discovered on disk
replaced: boolean; // whether table contents were replaced by this call
partial: boolean; // replaced, but some files were skipped
failures: IndexFailure[];
}
interface IndexFailure {
file: string; // path relative to contentPath
stage: "read" | "parse" | "embed";
error: Error;
}Behavior notes:
- every file is read, parsed, and embedded in memory before any database state changes
- the target table is then replaced in a single write transaction, so a failed rebuild leaves the previous index exactly as it was
- that costs peak memory proportional to the whole corpus, and against remote clients the replacement travels as a single un-chunked batch request; see Costs of the two-phase rebuild before rebuilding a very large corpus in place
- files are discovered and indexed in sorted path order
- frontmatter
titlemust be a scalar; a structured title such as a YAML list fails the file at theparsestage - two files that reduce to the same slug (
foo.mdandfoo.markdown) collide: the first in sorted path order keeps the slug and the later file is reported as aparsefailure failurePolicy: "abort"throwsIndexingErroron the first file that failsfailurePolicy: "skip"drops the failing file, records it infailures, and rebuilds from the survivors, returningpartial: true- under
"skip", if every discovered file fails, the rebuild throws instead of replacing a valid index with an empty one - an empty source directory throws unless
allowEmptyIndex: true, which intentionally empties the index onProgressis called once per file during the build phase- frontmatter
title,description, andtagsare folded into the embedding text - embeddings default to
intent: "document"unlessembeddingOptions.intentis set explicitly - if a file has no frontmatter title, the filename becomes the title
Thrown when a rebuild cannot complete. The previously indexed rows are always left unchanged.
class IndexingError extends Error {
readonly phase: "build" | "replace";
readonly failures: IndexFailure[];
}phase: "build"means the failure happened before any database work: a file failed, every file failed, the source directory was empty, or it could not be scannedphase: "replace"means the replacement transaction failed and was rolled backcausecarries the underlying error- on a
phase: "replace"error,failureslists files skipped during the build phase. They are not the cause of the rollback, which is carried bycause
import { indexContent, IndexingError } from "libsql-search";
try {
await indexContent({
client,
contentPath: "./content",
embeddingOptions: {
provider: "openai-compatible",
baseUrl: process.env.EMBEDDING_BASE_URL!,
model: "bge-large-en-v1.5",
dimensions: 1024,
},
});
} catch (error) {
if (error instanceof IndexingError) {
console.error(error.phase, error.failures);
}
throw error;
}Breaking changes in this behavior:
- partial failures previously counted into
failedand still replaced the table; they now throw. PassfailurePolicy: "skip"for the previous lenient behavior. - an empty source directory previously returned zeros and left stale rows in place; it now throws. Pass
allowEmptyIndex: trueto intentionally empty the index.
Generates a query embedding and performs vector similarity search.
interface SearchOptions {
client: Client | DatabaseAdapter;
query: string;
limit?: number;
tableName?: string;
embeddingOptions: EmbeddingOptions;
candidates?: number;
exact?: boolean;
}Defaults:
limit:10tableName:"articles"candidates:Math.max(limit * 4, 32)exact:false
limit must be an integer from 1 through 100; invalid values are rejected before query embedding generation.
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.
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:
- Recall is approximate. A row the index does not return as a candidate cannot appear in the results, no matter its true distance. Raising
candidatesraises recall. - Ranking within the candidate set is exact. Distances on returned rows are always the true cosine distances, not index approximations.
- Ordering is fully deterministic. The
idtiebreaker 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. - Rows with a
NULLembedding are never returned. The vector index excludes them on its own.
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.
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.
// Trade query cost for recall on a large corpus
const results = await search({ client, query, embeddingOptions, limit: 10, candidates: 200 });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.
Related exported constants:
DEFAULT_SEARCH_CANDIDATE_MULTIPLIER(4): multiplier applied tolimitMIN_SEARCH_CANDIDATES(32): floor for the derived defaultMAX_SEARCH_CANDIDATES(1000): ceiling for any explicit value. The derived default cannot reach it today, sincelimittops out at100; the cap constrains explicit values.
Set exact: true to bypass the index and score every row in the table.
const results = await search({ client, query, embeddingOptions, exact: true });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.
A backend with no vector index at all takes this path whether or not exact is set, because there is no index path for it to fall back from. That is the case for @tursodatabase/database; see the Turso Database backend guide.
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.
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.
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.
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.
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.
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.
Result shape:
interface SearchResult {
id: number;
slug: string;
title: string;
content: string;
folder: string;
tags: string[];
distance: number;
created_at: string;
}Lower distance values are better matches.
Search embeddings default to intent: "query" unless embeddingOptions.intent is set explicitly.
Returns all indexed articles ordered by title.
Returns one article or null.
Returns articles in a specific folder.
Returns distinct folder names from the index.
All retrieval helpers validate tableName before executing SQL, and all of them accept either client kind.
interface EmbeddingOptions {
provider:
| "cloudflare"
| "mistral"
| "gemini"
| "openai"
| "openai-compatible";
apiKey?: string;
accountId?: string;
apiToken?: string;
baseUrl?: string;
model?: string;
batchSize?: number;
dimensions?: number;
maxLength?: number;
intent?: "document" | "query";
timeoutMs?: number;
signal?: AbortSignal;
}Important option rules:
provideris required; every provider is an external servicemaxLengthdefaults to8000timeoutMsdefaults to30000modelis only used byopenai-compatiblebaseUrl,model, anddimensionsare required foropenai-compatiblebatchSizeonly applies toopenai-compatibleand defaults to32openai-compatiblenever readsOPENAI_API_KEY- only the Gemini adapter currently changes payload formatting by
intent
Dimension rules:
- Cloudflare: fixed
1024 - Mistral: fixed
1024 - Gemini: default
3072, allowed integer range128-3072 - OpenAI: default
768;text-embedding-3-smallthrough1536,text-embedding-3-largeabove1536 - OpenAI-compatible: required positive integer, no default
See Provider matrix and credential rules for the canonical provider table.
Generates one embedding vector.
const embedding = await generateEmbedding("deploy docs", {
provider: "openai",
apiKey: process.env.OPENAI_API_KEY,
dimensions: 1536,
});Generates an ordered batch of embeddings.
- empty batches return
[]without configuring credentials or making a provider call - OpenAI batches above
2048inputs are rejected before network work openai-compatiblebatches are chunked sequentially according tobatchSize
Creates a provider client with immutable metadata and an embed(texts, options?) method.
const provider = createEmbeddingProvider({
provider: "openai-compatible",
baseUrl: "https://tei.example.internal/v1",
model: "bge-large-en-v1.5",
dimensions: 1024,
batchSize: 32,
});
console.log(provider.metadata);Provider clients return a rich EmbeddingBatchResult; the compatibility helpers generateEmbedding() and generateEmbeddings() return only vectors.
Hosted provider clients are scoped to their current options. The library does not reuse a Cloudflare, Mistral, Gemini, or OpenAI client across different credential sets or configurations.
Returns the same metadata exposed by createEmbeddingProvider(options).metadata without resolving hosted-provider credentials.
Metadata shape:
interface EmbeddingProviderMetadata {
name:
| "cloudflare"
| "mistral"
| "gemini"
| "openai"
| "openai-compatible";
model: string;
dimensions: number;
batch: {
mode: "native" | "sequential";
maxSize?: number;
};
}Batch interpretation:
"native"means the provider accepts a batch request upstream"sequential"means the library accepts a batch but processes items one-by-onebatch.maxSizeis a hard client-side limit when present
openai-compatible metadata reports batch.mode: "native" because the remote endpoint is expected to accept batch inputs, even though the library may split large arrays into sequential outbound chunks at batchSize.
interface EmbeddingBatchResult {
embeddings: number[][];
provider:
| "cloudflare"
| "mistral"
| "gemini"
| "openai"
| "openai-compatible";
model: string;
dimensions: number;
intent: "document" | "query";
}Validates provider responses before they reach the database:
- result count must match the requested input count
- vectors must match the effective dimensions
- values must be finite numbers
- indexed provider responses are reordered and checked for contiguous indices
Pads or truncates a vector to the target width. This is exported for compatibility and migration workflows; provider adapters otherwise validate and preserve the vectors returned by their external service.
Builds the text that is embedded from title, description, content, and tags.