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/README.md
CHANGED
|
@@ -74,11 +74,33 @@ console.log(results.map((result) => ({
|
|
|
74
74
|
|
|
75
75
|
Important behavior:
|
|
76
76
|
|
|
77
|
-
- Call `createTable()` before indexing or searching.
|
|
77
|
+
- Call `createTable()` before indexing or searching. It creates the `<tableName>_embedding_idx` vector index that `search()` needs.
|
|
78
78
|
- Keep table width, provider, and dimensions aligned across create/index/query.
|
|
79
|
-
- `indexContent()`
|
|
79
|
+
- `indexContent()` embeds every document before it touches the database, then replaces the table in one transaction, so a failed rebuild leaves the previous index intact.
|
|
80
|
+
- `indexContent()` throws `IndexingError` when a file fails; pass `failurePolicy: "skip"` to rebuild from the remaining files.
|
|
81
|
+
- `indexContent()` throws `IndexingError` when no source files are found; pass `allowEmptyIndex: true` to intentionally empty the index.
|
|
80
82
|
- Hosted providers send indexed and queried text to external services and may incur provider charges.
|
|
81
83
|
|
|
84
|
+
## Search Accuracy And Performance
|
|
85
|
+
|
|
86
|
+
`search()` queries the `<tableName>_embedding_idx` vector index through libSQL's `vector_top_k()`. It does not score every row, so query cost no longer grows linearly with the size of the index.
|
|
87
|
+
|
|
88
|
+
That index is an approximate-nearest-neighbor structure, so **the default search path is approximate and can miss a true nearest neighbor.** To limit the loss, `search()` over-fetches candidates from the index, recomputes the true cosine distance for each, and orders exactly by `(distance, id)` before trimming to `limit`. Distances on returned rows are always exact, and result ordering is fully deterministic — including when two rows tie — even though the index's own candidate order is not.
|
|
89
|
+
|
|
90
|
+
Two options control the trade-off:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// Widen the index probe to raise recall (default: max(limit * 4, 32))
|
|
94
|
+
await search({ client, query, limit: 10, candidates: 200 });
|
|
95
|
+
|
|
96
|
+
// Bypass the index entirely: exact, but linear in table size
|
|
97
|
+
await search({ client, query, exact: true });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`exact: true` is the only way to guarantee exactness. Use it for small corpora, for correctness checks against the index path, and for tables that have no vector index.
|
|
101
|
+
|
|
102
|
+
Requirements: `vector_top_k()` and `libsql_vector_idx()` need a libSQL build with native vector support. The peer dependency is `@libsql/client ^0.15.0`, verified against `0.15.15`; remote Turso/libSQL servers must support vector indexes as well. See the [API reference](./docs/API.md#searchoptions) for full semantics, and [Indexing and operations](./docs/INDEXING.md) for tables created before the index existed.
|
|
103
|
+
|
|
82
104
|
## Providers
|
|
83
105
|
|
|
84
106
|
Built-in providers:
|
package/dist/index.cjs
CHANGED
|
@@ -788,6 +788,9 @@ function prepareTextForEmbedding(fields) {
|
|
|
788
788
|
const SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
789
789
|
const DEFAULT_SEARCH_LIMIT = 10;
|
|
790
790
|
const MAX_SEARCH_LIMIT = 100;
|
|
791
|
+
const DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = 4;
|
|
792
|
+
const MIN_SEARCH_CANDIDATES = 32;
|
|
793
|
+
const MAX_SEARCH_CANDIDATES = MAX_SEARCH_LIMIT * 10;
|
|
791
794
|
function validateSqlIdentifier(identifier, name = "identifier") {
|
|
792
795
|
if (typeof identifier !== "string" || !SQL_IDENTIFIER_PATTERN.test(identifier)) {
|
|
793
796
|
throw new Error(
|
|
@@ -806,6 +809,23 @@ function normalizeSearchLimit(limit = DEFAULT_SEARCH_LIMIT) {
|
|
|
806
809
|
}
|
|
807
810
|
return limit;
|
|
808
811
|
}
|
|
812
|
+
function defaultSearchCandidates(limit) {
|
|
813
|
+
return Math.min(
|
|
814
|
+
Math.max(limit * DEFAULT_SEARCH_CANDIDATE_MULTIPLIER, MIN_SEARCH_CANDIDATES),
|
|
815
|
+
MAX_SEARCH_CANDIDATES
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
function normalizeSearchCandidates(candidates, limit) {
|
|
819
|
+
if (candidates === void 0) {
|
|
820
|
+
return defaultSearchCandidates(limit);
|
|
821
|
+
}
|
|
822
|
+
if (typeof candidates !== "number" || !Number.isFinite(candidates) || !Number.isInteger(candidates) || candidates < limit || candidates > MAX_SEARCH_CANDIDATES) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Invalid search candidates: expected an integer from the search limit (${limit}) to ${MAX_SEARCH_CANDIDATES}`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return candidates;
|
|
828
|
+
}
|
|
809
829
|
function normalizeVectorDimensions(dimensions) {
|
|
810
830
|
if (typeof dimensions !== "number" || !Number.isFinite(dimensions) || !Number.isInteger(dimensions) || dimensions < 1) {
|
|
811
831
|
throw new Error("Invalid vector dimensions: expected a positive integer");
|
|
@@ -813,6 +833,16 @@ function normalizeVectorDimensions(dimensions) {
|
|
|
813
833
|
return dimensions;
|
|
814
834
|
}
|
|
815
835
|
|
|
836
|
+
class IndexingError extends Error {
|
|
837
|
+
phase;
|
|
838
|
+
failures;
|
|
839
|
+
constructor(message, phase, failures = [], options) {
|
|
840
|
+
super(message, options);
|
|
841
|
+
this.name = "IndexingError";
|
|
842
|
+
this.phase = phase;
|
|
843
|
+
this.failures = failures;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
816
846
|
async function indexContent(options) {
|
|
817
847
|
const {
|
|
818
848
|
client,
|
|
@@ -821,32 +851,92 @@ async function indexContent(options) {
|
|
|
821
851
|
fileExtensions = [".md", ".markdown"],
|
|
822
852
|
exclude = ["node_modules", ".git", "dist", "build"],
|
|
823
853
|
tableName = "articles",
|
|
824
|
-
onProgress
|
|
854
|
+
onProgress,
|
|
855
|
+
failurePolicy = "abort",
|
|
856
|
+
allowEmptyIndex = false
|
|
825
857
|
} = options;
|
|
826
858
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
859
|
+
let files;
|
|
860
|
+
try {
|
|
861
|
+
files = await findFiles(contentPath, contentPath, fileExtensions, exclude);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
throw new IndexingError(
|
|
864
|
+
`Failed to scan ${contentPath} for source files. The existing index was left unchanged.`,
|
|
865
|
+
"build",
|
|
866
|
+
[],
|
|
867
|
+
{ cause: toError(error) }
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
files.sort((a, b) => a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0);
|
|
871
|
+
if (files.length === 0 && !allowEmptyIndex) {
|
|
872
|
+
throw new IndexingError(
|
|
873
|
+
`No source files found in ${contentPath}. The existing index was left unchanged. Pass allowEmptyIndex: true to intentionally empty the index.`,
|
|
874
|
+
"build"
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
const documents = [];
|
|
878
|
+
const failures = [];
|
|
879
|
+
const slugOwners = /* @__PURE__ */ new Map();
|
|
835
880
|
for (let i = 0; i < files.length; i++) {
|
|
836
881
|
const file = files[i];
|
|
837
882
|
if (onProgress) {
|
|
838
883
|
onProgress(i + 1, files.length, file.relativePath);
|
|
839
884
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
885
|
+
const outcome = await buildDocument(file, embeddingOptions);
|
|
886
|
+
let failure;
|
|
887
|
+
if (outcome.ok) {
|
|
888
|
+
const owner = slugOwners.get(outcome.document.slug);
|
|
889
|
+
if (owner === void 0) {
|
|
890
|
+
slugOwners.set(outcome.document.slug, file.relativePath);
|
|
891
|
+
documents.push(outcome.document);
|
|
892
|
+
} else {
|
|
893
|
+
failure = {
|
|
894
|
+
file: file.relativePath,
|
|
895
|
+
stage: "parse",
|
|
896
|
+
error: new Error(
|
|
897
|
+
`Duplicate slug "${outcome.document.slug}": ${file.relativePath} collides with ${owner}`
|
|
898
|
+
)
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
failure = {
|
|
903
|
+
file: file.relativePath,
|
|
904
|
+
stage: outcome.stage,
|
|
905
|
+
error: outcome.error
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (failure === void 0) {
|
|
909
|
+
continue;
|
|
847
910
|
}
|
|
911
|
+
if (failurePolicy === "abort") {
|
|
912
|
+
throw new IndexingError(
|
|
913
|
+
`Failed to ${failure.stage} ${file.relativePath}: ${failure.error.message}. The existing index was left unchanged. Pass failurePolicy: 'skip' to rebuild from the remaining files.`,
|
|
914
|
+
"build",
|
|
915
|
+
[failure],
|
|
916
|
+
{ cause: failure.error }
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
console.error(`Skipping ${file.relativePath} (${failure.stage} failed):`, failure.error);
|
|
920
|
+
failures.push(failure);
|
|
921
|
+
}
|
|
922
|
+
if (files.length > 0 && documents.length === 0) {
|
|
923
|
+
throw new IndexingError(
|
|
924
|
+
`All ${files.length} source file(s) in ${contentPath} failed to index. The existing index was left unchanged.`,
|
|
925
|
+
"build",
|
|
926
|
+
failures,
|
|
927
|
+
{ cause: failures[0].error }
|
|
928
|
+
);
|
|
848
929
|
}
|
|
849
|
-
|
|
930
|
+
const success = documents.length;
|
|
931
|
+
await replaceIndex(client, quotedTableName, documents, failures);
|
|
932
|
+
return {
|
|
933
|
+
success,
|
|
934
|
+
failed: failures.length,
|
|
935
|
+
total: files.length,
|
|
936
|
+
replaced: true,
|
|
937
|
+
partial: failures.length > 0,
|
|
938
|
+
failures
|
|
939
|
+
};
|
|
850
940
|
}
|
|
851
941
|
async function findFiles(dir, baseDir, extensions, exclude) {
|
|
852
942
|
const files = [];
|
|
@@ -870,34 +960,113 @@ async function findFiles(dir, baseDir, extensions, exclude) {
|
|
|
870
960
|
}
|
|
871
961
|
return files;
|
|
872
962
|
}
|
|
873
|
-
async function
|
|
874
|
-
|
|
875
|
-
|
|
963
|
+
async function buildDocument(file, embeddingOptions) {
|
|
964
|
+
let raw;
|
|
965
|
+
try {
|
|
966
|
+
raw = await promises.readFile(file.fullPath, "utf-8");
|
|
967
|
+
} catch (error) {
|
|
968
|
+
return { ok: false, stage: "read", error: toError(error) };
|
|
969
|
+
}
|
|
970
|
+
let parsed;
|
|
971
|
+
try {
|
|
972
|
+
parsed = parseFile(file, raw);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return { ok: false, stage: "parse", error: toError(error) };
|
|
975
|
+
}
|
|
976
|
+
let embedding;
|
|
977
|
+
try {
|
|
978
|
+
embedding = await generateEmbedding(parsed.embeddingText, {
|
|
979
|
+
...embeddingOptions,
|
|
980
|
+
intent: embeddingOptions.intent ?? "document"
|
|
981
|
+
});
|
|
982
|
+
} catch (error) {
|
|
983
|
+
return { ok: false, stage: "embed", error: toError(error) };
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
ok: true,
|
|
987
|
+
document: {
|
|
988
|
+
slug: parsed.slug,
|
|
989
|
+
title: parsed.title,
|
|
990
|
+
content: parsed.content,
|
|
991
|
+
folder: file.folder,
|
|
992
|
+
tags: parsed.tags,
|
|
993
|
+
serializedTags: parsed.serializedTags,
|
|
994
|
+
embedding,
|
|
995
|
+
metadata: parsed.metadata
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
function parseFile(file, raw) {
|
|
1000
|
+
const { data: frontMatter, content: markdown } = matter(raw);
|
|
876
1001
|
const slug = file.relativePath.replace(/\.(md|markdown)$/, "").replace(/\\/g, "/");
|
|
877
|
-
const title = frontMatter.title
|
|
1002
|
+
const title = resolveTitle(file, frontMatter.title);
|
|
878
1003
|
const tags = Array.isArray(frontMatter.tags) ? frontMatter.tags : [];
|
|
1004
|
+
const serializedTags = JSON.stringify(tags);
|
|
879
1005
|
const embeddingText = prepareTextForEmbedding({
|
|
880
1006
|
title,
|
|
881
1007
|
description: frontMatter.description,
|
|
882
1008
|
content: markdown,
|
|
883
1009
|
tags
|
|
884
1010
|
});
|
|
885
|
-
const embedding = await generateEmbedding(embeddingText, {
|
|
886
|
-
...embeddingOptions,
|
|
887
|
-
intent: embeddingOptions.intent ?? "document"
|
|
888
|
-
});
|
|
889
1011
|
return {
|
|
890
1012
|
slug,
|
|
891
1013
|
title,
|
|
892
1014
|
content: markdown,
|
|
893
|
-
folder: file.folder,
|
|
894
1015
|
tags,
|
|
895
|
-
|
|
1016
|
+
serializedTags,
|
|
1017
|
+
embeddingText,
|
|
896
1018
|
metadata: frontMatter
|
|
897
1019
|
};
|
|
898
1020
|
}
|
|
899
|
-
|
|
900
|
-
|
|
1021
|
+
function resolveTitle(file, rawTitle) {
|
|
1022
|
+
if (!rawTitle) {
|
|
1023
|
+
return fallbackTitle(file);
|
|
1024
|
+
}
|
|
1025
|
+
if (typeof rawTitle === "string") {
|
|
1026
|
+
return rawTitle;
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof rawTitle === "number" || typeof rawTitle === "bigint" || typeof rawTitle === "boolean") {
|
|
1029
|
+
return String(rawTitle);
|
|
1030
|
+
}
|
|
1031
|
+
if (rawTitle instanceof Date) {
|
|
1032
|
+
return rawTitle.toISOString();
|
|
1033
|
+
}
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
`Unsupported frontmatter title of type ${describeType(rawTitle)}: expected a string`
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
function fallbackTitle(file) {
|
|
1039
|
+
return file.relativePath.split("/").pop()?.replace(/\.(md|markdown)$/, "").replace(/-/g, " ") || "Untitled";
|
|
1040
|
+
}
|
|
1041
|
+
function describeType(value) {
|
|
1042
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
1043
|
+
}
|
|
1044
|
+
async function replaceIndex(client, quotedTableName, documents, failures) {
|
|
1045
|
+
const statements = [`DELETE FROM ${quotedTableName}`];
|
|
1046
|
+
try {
|
|
1047
|
+
for (let i = 0; i < documents.length; i++) {
|
|
1048
|
+
const document = documents[i];
|
|
1049
|
+
if (document !== void 0) {
|
|
1050
|
+
statements.push(createInsertStatement(document, quotedTableName));
|
|
1051
|
+
documents[i] = void 0;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
} finally {
|
|
1055
|
+
documents.length = 0;
|
|
1056
|
+
}
|
|
1057
|
+
try {
|
|
1058
|
+
await client.batch(statements, "write");
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
throw new IndexingError(
|
|
1061
|
+
`Failed to replace the contents of ${quotedTableName}. The transaction was rolled back and the existing index was left unchanged.`,
|
|
1062
|
+
"replace",
|
|
1063
|
+
failures,
|
|
1064
|
+
{ cause: toError(error) }
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
function createInsertStatement(document, quotedTableName) {
|
|
1069
|
+
return {
|
|
901
1070
|
sql: `INSERT INTO ${quotedTableName}
|
|
902
1071
|
(slug, title, content, folder, tags, embedding, created_at, updated_at)
|
|
903
1072
|
VALUES (?, ?, ?, ?, ?, vector(?), datetime('now'), datetime('now'))`,
|
|
@@ -906,10 +1075,13 @@ async function insertDocument(client, document, quotedTableName) {
|
|
|
906
1075
|
document.title,
|
|
907
1076
|
document.content,
|
|
908
1077
|
document.folder,
|
|
909
|
-
|
|
1078
|
+
document.serializedTags,
|
|
910
1079
|
JSON.stringify(document.embedding)
|
|
911
1080
|
]
|
|
912
|
-
}
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
function toError(error) {
|
|
1084
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
913
1085
|
}
|
|
914
1086
|
async function createTable(client, tableName = "articles", dimensions = 384) {
|
|
915
1087
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
@@ -944,39 +1116,102 @@ async function createTable(client, tableName = "articles", dimensions = 384) {
|
|
|
944
1116
|
`);
|
|
945
1117
|
}
|
|
946
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
|
+
];
|
|
947
1126
|
async function search(options) {
|
|
948
1127
|
const {
|
|
949
1128
|
client,
|
|
950
1129
|
query,
|
|
951
1130
|
limit = 10,
|
|
952
1131
|
tableName = "articles",
|
|
953
|
-
embeddingOptions = {}
|
|
1132
|
+
embeddingOptions = {},
|
|
1133
|
+
candidates,
|
|
1134
|
+
exact = false
|
|
954
1135
|
} = options;
|
|
955
1136
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
956
1137
|
const resultLimit = normalizeSearchLimit(limit);
|
|
1138
|
+
const embeddingIndexName = validateSqlIdentifier(
|
|
1139
|
+
`${tableName}_embedding_idx`,
|
|
1140
|
+
"embedding index name"
|
|
1141
|
+
);
|
|
1142
|
+
const candidateCount = normalizeSearchCandidates(candidates, resultLimit);
|
|
957
1143
|
const queryEmbedding = await generateEmbedding(query, {
|
|
958
1144
|
...embeddingOptions,
|
|
959
1145
|
intent: embeddingOptions.intent ?? "query"
|
|
960
1146
|
});
|
|
961
|
-
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({
|
|
962
1183
|
sql: `
|
|
963
1184
|
SELECT
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
created_at,
|
|
971
|
-
vector_distance_cos(embedding, vector(?)) as distance
|
|
972
|
-
FROM ${quotedTableName}
|
|
973
|
-
WHERE embedding IS NOT NULL
|
|
974
|
-
ORDER BY distance
|
|
975
|
-
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
|
|
976
1191
|
`,
|
|
977
|
-
args:
|
|
1192
|
+
args: { queryVector, resultLimit }
|
|
978
1193
|
});
|
|
979
|
-
|
|
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 {
|
|
980
1215
|
id: row.id,
|
|
981
1216
|
slug: row.slug,
|
|
982
1217
|
title: row.title,
|
|
@@ -985,7 +1220,7 @@ async function search(options) {
|
|
|
985
1220
|
tags: JSON.parse(row.tags || "[]"),
|
|
986
1221
|
distance: row.distance,
|
|
987
1222
|
created_at: row.created_at
|
|
988
|
-
}
|
|
1223
|
+
};
|
|
989
1224
|
}
|
|
990
1225
|
async function getAllArticles(client, tableName = "articles") {
|
|
991
1226
|
const quotedTableName = quoteSqlIdentifier(tableName, "tableName");
|
|
@@ -1059,6 +1294,10 @@ async function getFolders(client, tableName = "articles") {
|
|
|
1059
1294
|
return results.rows.map((row) => row.folder);
|
|
1060
1295
|
}
|
|
1061
1296
|
|
|
1297
|
+
exports.DEFAULT_SEARCH_CANDIDATE_MULTIPLIER = DEFAULT_SEARCH_CANDIDATE_MULTIPLIER;
|
|
1298
|
+
exports.IndexingError = IndexingError;
|
|
1299
|
+
exports.MAX_SEARCH_CANDIDATES = MAX_SEARCH_CANDIDATES;
|
|
1300
|
+
exports.MIN_SEARCH_CANDIDATES = MIN_SEARCH_CANDIDATES;
|
|
1062
1301
|
exports.createEmbeddingProvider = createEmbeddingProvider;
|
|
1063
1302
|
exports.createTable = createTable;
|
|
1064
1303
|
exports.generateEmbedding = generateEmbedding;
|
package/dist/index.d.ts
CHANGED
|
@@ -82,6 +82,28 @@ declare function prepareTextForEmbedding(fields: {
|
|
|
82
82
|
* Content indexer for markdown and other formats
|
|
83
83
|
*/
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* How build-phase failures are handled.
|
|
87
|
+
*
|
|
88
|
+
* - `abort` (default) rejects the whole rebuild on the first failure
|
|
89
|
+
* - `skip` drops the failing file and rebuilds from the survivors
|
|
90
|
+
*/
|
|
91
|
+
type IndexFailurePolicy = 'abort' | 'skip';
|
|
92
|
+
/** The step that failed while turning a file into an indexable document. */
|
|
93
|
+
type IndexFailureStage = 'read' | 'parse' | 'embed';
|
|
94
|
+
/**
|
|
95
|
+
* The phase an {@link IndexingError} was raised in.
|
|
96
|
+
*
|
|
97
|
+
* - `build` means no database state was touched
|
|
98
|
+
* - `replace` means the replacement transaction failed and was rolled back
|
|
99
|
+
*/
|
|
100
|
+
type IndexingErrorPhase = 'build' | 'replace';
|
|
101
|
+
interface IndexFailure {
|
|
102
|
+
/** Path of the file relative to `contentPath`. */
|
|
103
|
+
file: string;
|
|
104
|
+
stage: IndexFailureStage;
|
|
105
|
+
error: Error;
|
|
106
|
+
}
|
|
85
107
|
interface IndexerOptions {
|
|
86
108
|
client: Client;
|
|
87
109
|
contentPath: string;
|
|
@@ -90,6 +112,23 @@ interface IndexerOptions {
|
|
|
90
112
|
exclude?: string[];
|
|
91
113
|
tableName?: string;
|
|
92
114
|
onProgress?: (current: number, total: number, file: string) => void;
|
|
115
|
+
/** Defaults to `abort`. */
|
|
116
|
+
failurePolicy?: IndexFailurePolicy;
|
|
117
|
+
/** Allow an empty source directory to empty the index. Defaults to `false`. */
|
|
118
|
+
allowEmptyIndex?: boolean;
|
|
119
|
+
}
|
|
120
|
+
interface IndexResult {
|
|
121
|
+
/** Documents written to the table. */
|
|
122
|
+
success: number;
|
|
123
|
+
/** Files that could not be indexed. */
|
|
124
|
+
failed: number;
|
|
125
|
+
/** Files discovered on disk. */
|
|
126
|
+
total: number;
|
|
127
|
+
/** Whether table contents were replaced by this call. */
|
|
128
|
+
replaced: boolean;
|
|
129
|
+
/** Replaced, but some files were skipped. */
|
|
130
|
+
partial: boolean;
|
|
131
|
+
failures: IndexFailure[];
|
|
93
132
|
}
|
|
94
133
|
interface IndexedDocument {
|
|
95
134
|
slug: string;
|
|
@@ -101,18 +140,48 @@ interface IndexedDocument {
|
|
|
101
140
|
metadata?: Record<string, any>;
|
|
102
141
|
}
|
|
103
142
|
/**
|
|
104
|
-
*
|
|
143
|
+
* Raised when a rebuild cannot complete. The previously indexed rows are always
|
|
144
|
+
* left exactly as they were.
|
|
105
145
|
*/
|
|
106
|
-
declare
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
146
|
+
declare class IndexingError extends Error {
|
|
147
|
+
readonly phase: IndexingErrorPhase;
|
|
148
|
+
readonly failures: IndexFailure[];
|
|
149
|
+
constructor(message: string, phase: IndexingErrorPhase, failures?: IndexFailure[], options?: ErrorOptions);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Index markdown content from a directory.
|
|
153
|
+
*
|
|
154
|
+
* Every document is read, parsed, and embedded in memory before any database
|
|
155
|
+
* state changes. The table is then replaced inside a single write transaction,
|
|
156
|
+
* so a failure at any point leaves the previous index intact.
|
|
157
|
+
*/
|
|
158
|
+
declare function indexContent(options: IndexerOptions): Promise<IndexResult>;
|
|
111
159
|
/**
|
|
112
160
|
* Create the articles table if it doesn't exist
|
|
113
161
|
*/
|
|
114
162
|
declare function createTable(client: Client, tableName?: string, dimensions?: number): Promise<void>;
|
|
115
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
|
+
|
|
116
185
|
/**
|
|
117
186
|
* Vector search functionality
|
|
118
187
|
*/
|
|
@@ -123,6 +192,22 @@ interface SearchOptions {
|
|
|
123
192
|
limit?: number;
|
|
124
193
|
tableName?: string;
|
|
125
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;
|
|
126
211
|
}
|
|
127
212
|
interface SearchResult {
|
|
128
213
|
id: number;
|
|
@@ -135,7 +220,17 @@ interface SearchResult {
|
|
|
135
220
|
created_at: string;
|
|
136
221
|
}
|
|
137
222
|
/**
|
|
138
|
-
* 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.
|
|
139
234
|
*/
|
|
140
235
|
declare function search(options: SearchOptions): Promise<SearchResult[]>;
|
|
141
236
|
/**
|
|
@@ -178,5 +273,5 @@ declare function getArticlesByFolder(client: Client, folder: string, tableName?:
|
|
|
178
273
|
*/
|
|
179
274
|
declare function getFolders(client: Client, tableName?: string): Promise<string[]>;
|
|
180
275
|
|
|
181
|
-
export { createEmbeddingProvider, createTable, generateEmbedding, generateEmbeddings, getAllArticles, getArticleBySlug, getArticlesByFolder, getEmbeddingProviderMetadata, getFolders, indexContent, padEmbedding, prepareTextForEmbedding, search, validateEmbeddingBatch };
|
|
182
|
-
export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexedDocument, IndexerOptions, SearchOptions, SearchResult };
|
|
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 };
|
|
277
|
+
export type { EmbeddingBatchBehavior, EmbeddingBatchItem, EmbeddingBatchItemResult, EmbeddingBatchMode, EmbeddingBatchResult, EmbeddingIntent, EmbeddingOptions, EmbeddingProvider, EmbeddingProviderClient, EmbeddingProviderMetadata, EmbeddingRequestOptions, IndexFailure, IndexFailurePolicy, IndexFailureStage, IndexResult, IndexedDocument, IndexerOptions, IndexingErrorPhase, SearchOptions, SearchResult };
|