@larkup/vector-stores 0.1.14

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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@larkup/vector-stores",
3
+ "version": "0.1.14",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "exports": {
9
+ "./adapter": "./src/adapters/base.ts",
10
+ "./factory": "./src/factory.ts",
11
+ "./registry": "./src/registry.ts",
12
+ "./lancedb": "./src/adapters/lancedb.ts",
13
+ "./pinecone": "./src/adapters/pinecone.ts",
14
+ "./chroma": "./src/adapters/chroma.ts"
15
+ },
16
+ "dependencies": {
17
+ "@lancedb/lancedb": "^0.30.0",
18
+ "@pinecone-database/pinecone": "^7.2.0"
19
+ },
20
+ "peerDependencies": {
21
+ "@larkup/core": "workspace:*"
22
+ },
23
+ "devDependencies": {
24
+ "chromadb": "^1.10.5",
25
+ "typescript": "5.7.3"
26
+ }
27
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Common contract every vector-store adapter implements.
3
+ *
4
+ * The indexer talks to this interface only, so swapping LanceDB ⇆ Pinecone is
5
+ * purely a config change. The same shape is what the Phase 4 generated server
6
+ * will rely on, keeping local + deployed retrieval behaviour identical.
7
+ */
8
+
9
+ export interface VectorRecord {
10
+ id: string
11
+ vector: number[]
12
+ text: string
13
+ title: string
14
+ url?: string
15
+ source: string
16
+ documentId: string
17
+ chunkIndex: number
18
+ metadata?: Record<string, any>
19
+ }
20
+
21
+ export interface QueryHit {
22
+ id: string
23
+ score: number
24
+ text: string
25
+ title: string
26
+ url?: string
27
+ documentId: string
28
+ metadata?: Record<string, any>
29
+ }
30
+
31
+ export interface VectorStoreAdapter {
32
+ /** Create / connect to the index, sized for `dimensions`. */
33
+ init(dimensions: number): Promise<void>
34
+ /** Remove all existing vectors (full re-index). */
35
+ reset(): Promise<void>
36
+ /** Upsert a batch of records. */
37
+ upsert(records: VectorRecord[]): Promise<void>
38
+ /** Number of vectors currently stored, if cheaply knowable. */
39
+ count(): Promise<number | null>
40
+ /**
41
+ * Nearest-neighbour search for a query vector.
42
+ * Pass `queryText` for hybrid/lexical stores so they can also generate a
43
+ * sparse vector and merge dense + sparse results (RRF).
44
+ */
45
+ query(vector: number[], topK: number, queryText?: string): Promise<QueryHit[]>
46
+ /** Test connection to the vector store. Throws if invalid. */
47
+ testConnection(dimensions: number): Promise<void>
48
+ }
@@ -0,0 +1,402 @@
1
+ import type { QueryHit, VectorRecord, VectorStoreAdapter } from "./base";
2
+
3
+ /**
4
+ * Chroma adapter — self-hosted (client-server) or Chroma Cloud.
5
+ *
6
+ * ── Server (self-hosted) ──────────────────────────────────────────────────
7
+ * Connects to a running Chroma instance via HTTP (default localhost:8000).
8
+ * Optionally uses a static auth token for secured servers.
9
+ *
10
+ * ── Cloud (Chroma Cloud) ──────────────────────────────────────────────────
11
+ * Connects to Chroma Cloud with API key, tenant, and database.
12
+ *
13
+ * ── Features ──────────────────────────────────────────────────────────────
14
+ * - Collections: getOrCreateCollection with cosine distance
15
+ * - Metadata filtering: stores text, title, url, source, documentId, chunkIndex
16
+ * - Full-text search: where_document $contains for hybrid mode
17
+ * - Distance → score: score = 1 / (1 + distance) for cosine distance
18
+ *
19
+ * The `chromadb` package is imported dynamically so it only fails if the user
20
+ * selects Chroma without installing the optional dependency.
21
+ */
22
+
23
+ interface ChromaConfig {
24
+ mode?: string;
25
+ /** Server URL — used when mode is "server" */
26
+ host?: string;
27
+ /** Static auth token for server mode */
28
+ authToken?: string;
29
+ /** Cloud API key */
30
+ apiKey?: string;
31
+ /** Cloud tenant */
32
+ tenant?: string;
33
+ /** Cloud database */
34
+ database?: string;
35
+ /** Collection name */
36
+ collectionName?: string;
37
+ /** Index type for determining search behavior */
38
+ indexType?: string;
39
+ }
40
+
41
+ /** RRF constant for hybrid merge */
42
+ const RRF_K = 60;
43
+
44
+ export class ChromaAdapter implements VectorStoreAdapter {
45
+ private client: any = null;
46
+ private collection: any = null;
47
+ private readonly collectionName: string;
48
+
49
+ constructor(private readonly config: ChromaConfig) {
50
+ this.collectionName = config.collectionName?.trim() || "documents";
51
+ }
52
+
53
+ /**
54
+ * Lazily connect to the Chroma server or cloud.
55
+ * The `chromadb` package is dynamically imported to avoid module-eval
56
+ * failures when the package isn't installed.
57
+ */
58
+ private async getClient(): Promise<any> {
59
+ if (this.client) return this.client;
60
+
61
+ let chromadb: any;
62
+ try {
63
+ chromadb = await import("chromadb");
64
+ } catch {
65
+ throw new Error(
66
+ 'The "chromadb" package is not installed. Please install it first via the Configure page or run: pnpm add chromadb --filter @larkup/vector-stores',
67
+ );
68
+ }
69
+
70
+ if (this.config.mode === "cloud") {
71
+ if (!this.config.apiKey) {
72
+ throw new Error("Chroma Cloud requires an API key.");
73
+ }
74
+ const CloudClientClass = chromadb.CloudClient ?? chromadb.default?.CloudClient;
75
+ if (!CloudClientClass) {
76
+ throw new Error("Could not find CloudClient in the chromadb package.");
77
+ }
78
+ this.client = new CloudClientClass({
79
+ apiKey: this.config.apiKey,
80
+ tenant: this.config.tenant || "default_tenant",
81
+ database: this.config.database || "default_database",
82
+ });
83
+ } else {
84
+ // Server mode
85
+ const host = this.config.host?.trim() || "http://localhost:8000";
86
+ const ClientClass = chromadb.ChromaClient ?? chromadb.default?.ChromaClient;
87
+ if (!ClientClass) {
88
+ throw new Error("Could not find ChromaClient in the chromadb package.");
89
+ }
90
+ const opts: any = { path: host };
91
+ if (this.config.authToken) {
92
+ opts.auth = {
93
+ provider: "token",
94
+ credentials: this.config.authToken,
95
+ };
96
+ }
97
+ this.client = new ClientClass(opts);
98
+ }
99
+
100
+ return this.client;
101
+ }
102
+
103
+ private async getCollection(): Promise<any> {
104
+ if (this.collection) return this.collection;
105
+ const client = await this.getClient();
106
+ this.collection = await client.getOrCreateCollection({
107
+ name: this.collectionName,
108
+ metadata: { "hnsw:space": "cosine" },
109
+ });
110
+ return this.collection;
111
+ }
112
+
113
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
114
+
115
+ async init(_dimensions: number): Promise<void> {
116
+ await this.getCollection();
117
+ }
118
+
119
+ async reset(): Promise<void> {
120
+ const client = await this.getClient();
121
+ try {
122
+ await client.deleteCollection({ name: this.collectionName });
123
+ } catch {
124
+ /* collection may not exist yet — that's fine */
125
+ }
126
+ this.collection = null;
127
+ }
128
+
129
+ // ── Upsert ─────────────────────────────────────────────────────────────────
130
+
131
+ async upsert(records: VectorRecord[]): Promise<void> {
132
+ if (records.length === 0) return;
133
+ const collection = await this.getCollection();
134
+
135
+ // Chroma's upsert accepts parallel arrays
136
+ const ids: string[] = [];
137
+ const embeddings: number[][] = [];
138
+ const documents: string[] = [];
139
+ const metadatas: Record<string, any>[] = [];
140
+
141
+ for (const r of records) {
142
+ ids.push(r.id);
143
+ embeddings.push(r.vector);
144
+ documents.push(r.text);
145
+ metadatas.push(this.buildMetadata(r));
146
+ }
147
+
148
+ // Chroma has a max batch size of ~5000 for upsert, process in chunks
149
+ const BATCH = 4096;
150
+ for (let i = 0; i < ids.length; i += BATCH) {
151
+ await collection.upsert({
152
+ ids: ids.slice(i, i + BATCH),
153
+ embeddings: embeddings.slice(i, i + BATCH),
154
+ documents: documents.slice(i, i + BATCH),
155
+ metadatas: metadatas.slice(i, i + BATCH),
156
+ });
157
+ }
158
+ }
159
+
160
+ private buildMetadata(r: VectorRecord): Record<string, any> {
161
+ const meta: Record<string, any> = {
162
+ title: r.title,
163
+ source: r.source,
164
+ documentId: r.documentId,
165
+ chunkIndex: r.chunkIndex,
166
+ };
167
+ if (r.url) meta.url = r.url;
168
+ // Flatten user metadata (strings/numbers/booleans only — Chroma requirement)
169
+ if (r.metadata) {
170
+ for (const [k, v] of Object.entries(r.metadata)) {
171
+ if (v === null || v === undefined) continue;
172
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
173
+ meta[k] = v;
174
+ } else {
175
+ meta[k] = JSON.stringify(v);
176
+ }
177
+ }
178
+ }
179
+ return meta;
180
+ }
181
+
182
+ // ── Count ──────────────────────────────────────────────────────────────────
183
+
184
+ async count(): Promise<number | null> {
185
+ try {
186
+ const collection = await this.getCollection();
187
+ return await collection.count();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ // ── Query ──────────────────────────────────────────────────────────────────
194
+
195
+ async query(
196
+ vector: number[],
197
+ topK: number,
198
+ queryText?: string,
199
+ ): Promise<QueryHit[]> {
200
+ const collection = await this.getCollection();
201
+ const isSemantic = this.config.indexType === "semantic";
202
+ const isLexical = this.config.indexType === "lexical";
203
+
204
+ // Semantic only
205
+ if (isSemantic || !queryText?.trim()) {
206
+ return this.denseQuery(collection, vector, topK);
207
+ }
208
+
209
+ // Lexical only (using Chroma whereDocument full-text substring)
210
+ if (isLexical) {
211
+ return this.documentQuery(collection, queryText, topK);
212
+ }
213
+
214
+ // Hybrid search
215
+ const denseHits = await this.denseQuery(collection, vector, topK);
216
+
217
+ try {
218
+ const textHits = await this.documentQuery(collection, queryText, topK);
219
+ if (textHits.length > 0) {
220
+ return rrfMerge(denseHits, textHits, topK);
221
+ }
222
+ } catch {
223
+ // Full-text search fallback
224
+ }
225
+
226
+ return denseHits;
227
+ }
228
+
229
+ private async denseQuery(
230
+ collection: any,
231
+ vector: number[],
232
+ topK: number,
233
+ ): Promise<QueryHit[]> {
234
+ const result = await collection.query({
235
+ queryEmbeddings: [vector],
236
+ nResults: topK,
237
+ include: ["documents", "metadatas", "distances"],
238
+ });
239
+
240
+ return this.parseQueryResult(result);
241
+ }
242
+
243
+ private async documentQuery(
244
+ collection: any,
245
+ queryText: string,
246
+ topK: number,
247
+ ): Promise<QueryHit[]> {
248
+ // Chroma supports substring filtering natively via get() with whereDocument
249
+ const result = await collection.get({
250
+ whereDocument: { "$contains": queryText },
251
+ limit: topK,
252
+ include: ["documents", "metadatas"],
253
+ });
254
+
255
+ return this.parseGetResult(result);
256
+ }
257
+
258
+ private parseGetResult(result: any): QueryHit[] {
259
+ if (!result.ids || result.ids.length === 0) return [];
260
+
261
+ const ids = result.ids as string[];
262
+ const documents = result.documents ?? [];
263
+ const metadatas = result.metadatas ?? [];
264
+
265
+ return ids.map((id, i) => {
266
+ const meta = (metadatas[i] ?? {}) as Record<string, any>;
267
+ const score = 1.0; // Exact/lexical matches get max base score before RRF
268
+
269
+ const {
270
+ title,
271
+ url,
272
+ source,
273
+ documentId,
274
+ chunkIndex,
275
+ ...customMetadata
276
+ } = meta;
277
+
278
+ return {
279
+ id,
280
+ score,
281
+ text: (documents[i] as string) ?? "",
282
+ title: (title as string) ?? "Untitled",
283
+ url: (url as string) || undefined,
284
+ documentId: (documentId as string) ?? "",
285
+ metadata:
286
+ Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
287
+ };
288
+ });
289
+ }
290
+
291
+ private parseQueryResult(result: any): QueryHit[] {
292
+ if (!result.ids?.[0]) return [];
293
+
294
+ const ids = result.ids[0] as string[];
295
+ const documents = result.documents?.[0] ?? [];
296
+ const metadatas = result.metadatas?.[0] ?? [];
297
+ const distances = result.distances?.[0] ?? [];
298
+
299
+ return ids.map((id, i) => {
300
+ const meta = (metadatas[i] ?? {}) as Record<string, any>;
301
+ const distance = distances[i] ?? 0;
302
+ // Chroma cosine distance: 0 = identical, 2 = opposite
303
+ // Convert to a similarity score
304
+ const score = 1 / (1 + distance);
305
+
306
+ const {
307
+ title,
308
+ url,
309
+ source,
310
+ documentId,
311
+ chunkIndex,
312
+ ...customMetadata
313
+ } = meta;
314
+
315
+ return {
316
+ id,
317
+ score,
318
+ text: (documents[i] as string) ?? "",
319
+ title: (title as string) ?? "Untitled",
320
+ url: (url as string) || undefined,
321
+ documentId: (documentId as string) ?? "",
322
+ metadata:
323
+ Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
324
+ };
325
+ });
326
+ }
327
+
328
+ // ── Connection test ────────────────────────────────────────────────────────
329
+
330
+ async testConnection(_dimensions: number): Promise<void> {
331
+ let client: any;
332
+ try {
333
+ client = await this.getClient();
334
+ } catch (err: any) {
335
+ throw new Error(`Failed to connect to Chroma: ${err.message}`);
336
+ }
337
+
338
+ // heartbeat() is the lightest-weight RPC to verify connectivity
339
+ try {
340
+ await client.heartbeat();
341
+ } catch (err: any) {
342
+ const msg: string = err.message ?? "";
343
+
344
+ if (
345
+ msg.includes("401") ||
346
+ msg.includes("Unauthorized") ||
347
+ msg.includes("403") ||
348
+ msg.includes("Forbidden")
349
+ ) {
350
+ throw new Error(
351
+ "Invalid Chroma credentials. Please check your auth token or API key.",
352
+ );
353
+ }
354
+
355
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
356
+ throw new Error(
357
+ "Could not reach the Chroma server. Is it running and accessible at the configured URL?",
358
+ );
359
+ }
360
+
361
+ throw new Error(`Failed to reach Chroma server: ${msg}`);
362
+ }
363
+
364
+ // Verify we can access the specified tenant/database by listing collections
365
+ try {
366
+ await client.listCollections();
367
+ } catch (err: any) {
368
+ throw new Error(
369
+ `Connected to Chroma but failed to access the database or tenant. Please verify your tenant and database names. Error: ${err.message}`,
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ // ── Helpers ──────────────────────────────────────────────────────────────────
376
+
377
+ function rrfMerge(
378
+ denseHits: QueryHit[],
379
+ sparseHits: QueryHit[],
380
+ topK: number,
381
+ ): QueryHit[] {
382
+ const scores = new Map<string, { hit: QueryHit; score: number }>();
383
+
384
+ const addList = (hits: QueryHit[]) =>
385
+ hits.forEach((hit, rank) => {
386
+ const contrib = 1 / (RRF_K + rank + 1);
387
+ const prev = scores.get(hit.id);
388
+ if (prev) {
389
+ prev.score += contrib;
390
+ } else {
391
+ scores.set(hit.id, { hit, score: contrib });
392
+ }
393
+ });
394
+
395
+ addList(denseHits);
396
+ addList(sparseHits);
397
+
398
+ return [...scores.values()]
399
+ .sort((a, b) => b.score - a.score)
400
+ .slice(0, topK)
401
+ .map(({ hit, score }) => ({ ...hit, score }));
402
+ }
@@ -0,0 +1,220 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import * as lancedb from "@lancedb/lancedb";
4
+ import type { QueryHit, VectorRecord, VectorStoreAdapter } from "./base";
5
+
6
+ /**
7
+ * LanceDB adapter — embedded/on-disk for local dev, or LanceDB Cloud.
8
+ *
9
+ * The table schema is inferred from the first batch of rows we add, so `init`
10
+ * is a no-op connect; the table is (re)created lazily on `reset`/first upsert.
11
+ */
12
+
13
+ interface LanceConfig {
14
+ mode?: string;
15
+ dbPath?: string;
16
+ uri?: string;
17
+ apiKey?: string;
18
+ tableName?: string;
19
+ }
20
+
21
+ interface LanceRow extends Record<string, unknown> {
22
+ id: string;
23
+ vector: number[];
24
+ text: string;
25
+ title: string;
26
+ url: string;
27
+ source: string;
28
+ documentId: string;
29
+ chunkIndex: number;
30
+ metadata?: string;
31
+ }
32
+
33
+ export class LanceDBAdapter implements VectorStoreAdapter {
34
+ private conn: lancedb.Connection | null = null;
35
+ private table: lancedb.Table | null = null;
36
+ private readonly tableName: string;
37
+
38
+ constructor(private readonly config: LanceConfig) {
39
+ this.tableName = config.tableName?.trim() || "documents";
40
+ }
41
+
42
+ private async connect(): Promise<lancedb.Connection> {
43
+ if (this.conn) return this.conn;
44
+ if (this.config.mode === "cloud") {
45
+ if (!this.config.uri || !this.config.apiKey) {
46
+ throw new Error("LanceDB Cloud requires both a URI and an API key.");
47
+ }
48
+ if (!this.config.uri.startsWith("db://")) {
49
+ throw new Error(
50
+ "LanceDB Cloud URI must start with 'db://' (e.g. db://my-database).",
51
+ );
52
+ }
53
+ this.conn = await lancedb.connect(this.config.uri, {
54
+ apiKey: this.config.apiKey,
55
+ });
56
+ } else {
57
+ const dir = this.config.dbPath?.trim() || "./.larkup/lancedb";
58
+ let abs = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
59
+
60
+ try {
61
+ await fs.mkdir(abs, { recursive: true });
62
+ } catch (err: any) {
63
+ if (path.isAbsolute(dir)) {
64
+ // The user likely forgot the dot (e.g. typed "/lancedb" instead of "./lancedb").
65
+ // Automatically retry by prepending "." to make it relative to the workspace.
66
+ const fallbackDir = "." + dir;
67
+ abs = path.join(process.cwd(), fallbackDir);
68
+ try {
69
+ await fs.mkdir(abs, { recursive: true });
70
+ } catch (fallbackErr: any) {
71
+ throw new Error(
72
+ `Could not create directory for LanceDB at "${abs}": ${fallbackErr.message}`,
73
+ );
74
+ }
75
+ } else {
76
+ throw new Error(
77
+ `Could not create directory for LanceDB at "${abs}": ${err.message}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ this.conn = await lancedb.connect(abs);
83
+ }
84
+ return this.conn;
85
+ }
86
+
87
+ async init(): Promise<void> {
88
+ const conn = await this.connect();
89
+ const names = await conn.tableNames();
90
+ if (names.includes(this.tableName)) {
91
+ this.table = await conn.openTable(this.tableName);
92
+ }
93
+ }
94
+
95
+ async reset(): Promise<void> {
96
+ const conn = await this.connect();
97
+ const names = await conn.tableNames();
98
+ if (names.includes(this.tableName)) {
99
+ await conn.dropTable(this.tableName);
100
+ }
101
+ this.table = null;
102
+ }
103
+
104
+ private toRow(r: VectorRecord): LanceRow {
105
+ return {
106
+ id: r.id,
107
+ vector: r.vector,
108
+ text: r.text,
109
+ title: r.title,
110
+ url: r.url ?? "",
111
+ source: r.source,
112
+ documentId: r.documentId,
113
+ chunkIndex: r.chunkIndex,
114
+ metadata: r.metadata ? JSON.stringify(r.metadata) : undefined,
115
+ };
116
+ }
117
+
118
+ async upsert(records: VectorRecord[]): Promise<void> {
119
+ if (records.length === 0) return;
120
+ const conn = await this.connect();
121
+ const rows = records.map((r) => this.toRow(r));
122
+
123
+ if (!this.table) {
124
+ const names = await conn.tableNames();
125
+ if (names.includes(this.tableName)) {
126
+ this.table = await conn.openTable(this.tableName);
127
+ await this.table.add(rows);
128
+ } else {
129
+ // First batch defines the schema (incl. vector dimensions).
130
+ this.table = await conn.createTable(this.tableName, rows);
131
+ }
132
+ } else {
133
+ await this.table.add(rows);
134
+ }
135
+ }
136
+
137
+ async count(): Promise<number | null> {
138
+ if (!this.table) {
139
+ await this.init();
140
+ if (!this.table) return 0;
141
+ }
142
+ try {
143
+ return await this.table.countRows();
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ async query(vector: number[], topK: number): Promise<QueryHit[]> {
150
+ if (!this.table) {
151
+ await this.init();
152
+ if (!this.table) return [];
153
+ }
154
+ const rows = (await this.table
155
+ .search(vector)
156
+ .limit(topK)
157
+ .toArray()) as Array<LanceRow & { _distance?: number }>;
158
+
159
+ return rows.map((row) => ({
160
+ id: row.id as string,
161
+ // Convert L2 distance → a 0..1 similarity-ish score for display.
162
+ score: typeof row._distance === "number" ? 1 / (1 + row._distance) : 0,
163
+ text: row.text as string,
164
+ title: row.title as string,
165
+ url: (row.url as string) || undefined,
166
+ documentId: row.documentId as string,
167
+ metadata:
168
+ typeof row.metadata === "string" ? JSON.parse(row.metadata) : undefined,
169
+ }));
170
+ }
171
+
172
+ async testConnection(dimensions: number): Promise<void> {
173
+ // ── Local mode: just verify the directory is reachable ──────────────────
174
+ if (this.config.mode !== "cloud") {
175
+ try {
176
+ await this.connect();
177
+ return;
178
+ } catch (err: any) {
179
+ throw new Error(`Failed to connect to local LanceDB: ${err.message}`);
180
+ }
181
+ }
182
+
183
+ // ── Cloud mode ───────────────────────────────────────────────────────────
184
+ // connect() is lazy (never contacts the server), so we call tableNames()
185
+ // to force a real authenticated HTTP round-trip.
186
+ let conn: lancedb.Connection;
187
+ try {
188
+ conn = await this.connect();
189
+ } catch (err: any) {
190
+ throw new Error(`Failed to connect to LanceDB Cloud: ${err.message}`);
191
+ }
192
+
193
+ try {
194
+ await conn.tableNames();
195
+ } catch (err: any) {
196
+ const msg: string = err.message ?? "";
197
+
198
+ // The SDK puts the HTTP status INSIDE the message string (not as a
199
+ // property). Pattern: "Http error: ... 401 Unauthorized: Unauthorized"
200
+ if (
201
+ msg.includes("401") ||
202
+ msg.includes("Unauthorized") ||
203
+ msg.includes("403") ||
204
+ msg.includes("Forbidden")
205
+ ) {
206
+ throw new Error(
207
+ "Invalid LanceDB Cloud API key. Please check your credentials in the LanceDB Cloud dashboard.",
208
+ );
209
+ }
210
+
211
+ if (msg.includes("404") || msg.includes("Not Found")) {
212
+ throw new Error(
213
+ `LanceDB Cloud database not found. Please verify your URI (e.g. "db://my-database").`,
214
+ );
215
+ }
216
+
217
+ throw new Error(`Failed to reach LanceDB Cloud: ${msg}`);
218
+ }
219
+ }
220
+ }