@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.
@@ -0,0 +1,487 @@
1
+ import {
2
+ Pinecone,
3
+ type Index,
4
+ type RecordSparseValues,
5
+ } from "@pinecone-database/pinecone";
6
+ import type { QueryHit, VectorRecord, VectorStoreAdapter } from "./base";
7
+
8
+ /**
9
+ * Pinecone adapter — fully-managed cloud vector DB.
10
+ *
11
+ * ── Semantic (dense-only) ──────────────────────────────────────────────────
12
+ * Index metric: cosine (or dotproduct)
13
+ * Upsert: dense `values` only.
14
+ * Query: pure vector ANN search.
15
+ *
16
+ * ── Hybrid / Lexical ───────────────────────────────────────────────────────
17
+ * Index metric: MUST be dotproduct
18
+ * Upsert: dense `values` + sparse `sparseValues` in the SAME record.
19
+ * Sparse vectors are generated via Pinecone Inference API.
20
+ * Query: dense ANN + sparse keyword search run in parallel,
21
+ * merged via Reciprocal Rank Fusion (RRF, k=60).
22
+ *
23
+ * ── Rate limiting ─────────────────────────────────────────────────────────
24
+ * Pinecone's free plan caps the sparse model at 250k tokens/min.
25
+ * The adapter uses a conservative batch size + inter-batch delay to stay
26
+ * within that budget. On a 429 it pauses and retries automatically, calling
27
+ * the optional `onRateLimit` hook so the caller can surface a UI warning.
28
+ */
29
+
30
+ interface PineconeConfig {
31
+ apiKey?: string;
32
+ indexName?: string;
33
+ namespace?: string;
34
+ /** Pinecone Inference sparse model. Required for hybrid/lexical. */
35
+ sparseModel?: string;
36
+ /** indexType drives whether sparse vectors are generated */
37
+ indexType?: string;
38
+ /**
39
+ * Called just before each rate-limit sleep so the caller can update UI.
40
+ * `waitSecs` is how long we'll wait; `attempt` is which retry (1-based).
41
+ */
42
+ onRateLimit?: (waitSecs: number, attempt: number) => void | Promise<void>;
43
+ }
44
+
45
+ /** Must carry an index-signature to satisfy RecordMetadata */
46
+ interface PineMeta {
47
+ text: string;
48
+ title: string;
49
+ url: string;
50
+ source: string;
51
+ documentId: string;
52
+ chunkIndex: number;
53
+ [key: string]: string | number | boolean | string[];
54
+ }
55
+
56
+ /**
57
+ * Chunks per Inference API call.
58
+ * 48 chunks × 512 tokens (max) = 24,576 tokens per batch.
59
+ * With INTER_BATCH_DELAY_MS between calls that keeps us comfortably
60
+ * under Pinecone's 250k tokens/min starter limit.
61
+ */
62
+ const SPARSE_BATCH = 48;
63
+
64
+ /**
65
+ * Initial delay between consecutive sparse-embed batches (ms).
66
+ * Starts at 0 to index fast, but if a rate limit is hit, it will be
67
+ * increased dynamically to stay in budget.
68
+ */
69
+ const BASE_INTER_BATCH_DELAY_MS = 7_000;
70
+
71
+ /** How long to wait on a 429 before retrying (ms). */
72
+ const RATE_LIMIT_WAIT_MS = 65_000;
73
+
74
+ /** Maximum number of 429-retry attempts per batch. */
75
+ const RATE_LIMIT_MAX_RETRIES = 3;
76
+
77
+ /** RRF constant */
78
+ const RRF_K = 60;
79
+
80
+ function sleep(ms: number) {
81
+ return new Promise<void>((r) => setTimeout(r, ms));
82
+ }
83
+
84
+ function is429(err: unknown): boolean {
85
+ const e = err as any;
86
+ return (
87
+ e?.status === 429 ||
88
+ e?.statusCode === 429 ||
89
+ String(e?.message ?? "").includes("429") ||
90
+ String(e?.message ?? "").includes("RESOURCE_EXHAUSTED")
91
+ );
92
+ }
93
+
94
+ export class PineconeAdapter implements VectorStoreAdapter {
95
+ private client: Pinecone | null = null;
96
+ private index: Index | null = null;
97
+ private readonly indexName: string;
98
+ private readonly namespace: string;
99
+ private currentInterBatchDelayMs = 0;
100
+
101
+ constructor(private readonly config: PineconeConfig) {
102
+ if (!config.apiKey) throw new Error("Pinecone requires an API key.");
103
+ if (!config.indexName) throw new Error("Pinecone requires an index name.");
104
+ this.indexName = config.indexName.trim();
105
+ this.namespace = config.namespace?.trim() || "default";
106
+ }
107
+
108
+ private getClient() {
109
+ if (!this.client) {
110
+ this.client = new Pinecone({ apiKey: this.config.apiKey as string });
111
+ }
112
+ return this.client;
113
+ }
114
+
115
+ private get needsSparse() {
116
+ return (
117
+ this.config.indexType === "lexical" || this.config.indexType === "hybrid"
118
+ );
119
+ }
120
+
121
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
122
+
123
+ async init(dimensions: number): Promise<void> {
124
+ const pc = this.getClient();
125
+ const { indexes } = await pc.listIndexes();
126
+ const exists = (indexes ?? []).some((i) => i.name === this.indexName);
127
+
128
+ if (!exists) {
129
+ const metric = this.needsSparse ? "dotproduct" : "cosine";
130
+ await pc.createIndex({
131
+ name: this.indexName,
132
+ dimension: dimensions,
133
+ metric,
134
+ spec: { serverless: { cloud: "aws", region: "us-east-1" } },
135
+ waitUntilReady: true,
136
+ });
137
+ }
138
+ this.index = pc.index(this.indexName);
139
+ }
140
+
141
+ private ns() {
142
+ if (!this.index) this.index = this.getClient().index(this.indexName);
143
+ return this.index.namespace(this.namespace);
144
+ }
145
+
146
+ async reset(): Promise<void> {
147
+ try {
148
+ await this.ns().deleteAll();
149
+ } catch {
150
+ /* fresh index — ok */
151
+ }
152
+ }
153
+
154
+ // ── Sparse vector generation with rate-limit handling ─────────────────────
155
+
156
+ /**
157
+ * Call Pinecone Inference API for one batch of texts, retrying on 429.
158
+ * Before each retry-sleep the `onRateLimit` hook is called so the indexer
159
+ * can patch the run store with a warning that the UI polls.
160
+ */
161
+ private async embedSparseWithRetry(
162
+ texts: string[],
163
+ ): Promise<RecordSparseValues[]> {
164
+ const pc = this.getClient();
165
+
166
+ for (let attempt = 1; attempt <= RATE_LIMIT_MAX_RETRIES + 1; attempt++) {
167
+ try {
168
+ const result = await pc.inference.embed({
169
+ model: this.config.sparseModel!,
170
+ inputs: texts,
171
+ parameters: { input_type: "passage", truncate: "END" },
172
+ });
173
+ return result.data.map((emb) => {
174
+ const s = emb as any;
175
+ return {
176
+ indices: (s.sparseIndices as number[]) ?? [],
177
+ values: (s.sparseValues as number[]) ?? [],
178
+ };
179
+ });
180
+ } catch (err) {
181
+ if (is429(err) && attempt <= RATE_LIMIT_MAX_RETRIES) {
182
+ const waitSecs = Math.round(RATE_LIMIT_WAIT_MS / 1000);
183
+ await this.config.onRateLimit?.(waitSecs, attempt);
184
+ await sleep(RATE_LIMIT_WAIT_MS);
185
+ // Slow down future batches to avoid hitting the rate limit again
186
+ this.currentInterBatchDelayMs = Math.max(
187
+ this.currentInterBatchDelayMs,
188
+ BASE_INTER_BATCH_DELAY_MS,
189
+ );
190
+ continue;
191
+ }
192
+ throw err;
193
+ }
194
+ }
195
+ // Unreachable but TypeScript needs it
196
+ throw new Error("Sparse embedding failed after max retries.");
197
+ }
198
+
199
+ private async buildSparseVectors(
200
+ texts: string[],
201
+ ): Promise<RecordSparseValues[]> {
202
+ const result: RecordSparseValues[] = [];
203
+
204
+ for (let i = 0; i < texts.length; i += SPARSE_BATCH) {
205
+ const batch = texts.slice(i, i + SPARSE_BATCH);
206
+ const vecs = await this.embedSparseWithRetry(batch);
207
+ result.push(...vecs);
208
+
209
+ // Rate-limit guard: delay between batches (skip after the last one)
210
+ if (
211
+ i + SPARSE_BATCH < texts.length &&
212
+ this.currentInterBatchDelayMs > 0
213
+ ) {
214
+ await sleep(this.currentInterBatchDelayMs);
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ private async buildQuerySparseVector(
222
+ text: string,
223
+ ): Promise<RecordSparseValues> {
224
+ const pc = this.getClient();
225
+
226
+ for (let attempt = 1; attempt <= RATE_LIMIT_MAX_RETRIES + 1; attempt++) {
227
+ try {
228
+ const result = await pc.inference.embed({
229
+ model: this.config.sparseModel!,
230
+ inputs: [text],
231
+ parameters: { input_type: "query", truncate: "END" },
232
+ });
233
+ const s = result.data[0] as any;
234
+ return {
235
+ indices: (s.sparseIndices as number[]) ?? [],
236
+ values: (s.sparseValues as number[]) ?? [],
237
+ };
238
+ } catch (err) {
239
+ if (is429(err) && attempt <= RATE_LIMIT_MAX_RETRIES) {
240
+ await sleep(RATE_LIMIT_WAIT_MS);
241
+ continue;
242
+ }
243
+ throw err;
244
+ }
245
+ }
246
+ throw new Error("Sparse query embedding failed after max retries.");
247
+ }
248
+
249
+ // ── Upsert ─────────────────────────────────────────────────────────────────
250
+
251
+ async upsert(records: VectorRecord[]): Promise<void> {
252
+ if (records.length === 0) return;
253
+
254
+ const sanitizeMeta = (meta?: Record<string, any>): Record<string, any> => {
255
+ if (!meta) return {};
256
+ const safe: Record<string, any> = {};
257
+ for (const [k, v] of Object.entries(meta)) {
258
+ if (v === null || v === undefined) continue;
259
+ if (
260
+ typeof v === "string" ||
261
+ typeof v === "number" ||
262
+ typeof v === "boolean"
263
+ ) {
264
+ safe[k] = v;
265
+ } else if (
266
+ Array.isArray(v) &&
267
+ v.every((item) => typeof item === "string")
268
+ ) {
269
+ safe[k] = v;
270
+ } else {
271
+ safe[k] = JSON.stringify(v);
272
+ }
273
+ }
274
+ return safe;
275
+ };
276
+
277
+ const metaOf = (r: VectorRecord): PineMeta => ({
278
+ ...sanitizeMeta(r.metadata),
279
+ text: r.text,
280
+ title: r.title,
281
+ url: r.url ?? "",
282
+ source: r.source,
283
+ documentId: r.documentId,
284
+ chunkIndex: r.chunkIndex,
285
+ });
286
+
287
+ if (!this.needsSparse || !this.config.sparseModel) {
288
+ // ── Semantic-only ──────────────────────────────────────────────────────
289
+ await this.ns().upsert({
290
+ records: records.map((r) => ({
291
+ id: r.id,
292
+ values: r.vector,
293
+ metadata: metaOf(r),
294
+ })),
295
+ });
296
+ return;
297
+ }
298
+
299
+ // ── Hybrid / Lexical ───────────────────────────────────────────────────
300
+ const sparseVecs = await this.buildSparseVectors(
301
+ records.map((r) => r.text),
302
+ );
303
+
304
+ await this.ns().upsert({
305
+ records: records.map((r, i) => ({
306
+ id: r.id,
307
+ values: r.vector,
308
+ sparseValues: sparseVecs[i],
309
+ metadata: metaOf(r),
310
+ })),
311
+ });
312
+ }
313
+
314
+ // ── Query ──────────────────────────────────────────────────────────────────
315
+
316
+ async query(
317
+ vector: number[],
318
+ topK: number,
319
+ queryText?: string,
320
+ ): Promise<QueryHit[]> {
321
+ const canHybrid = this.needsSparse && this.config.sparseModel && queryText;
322
+
323
+ if (!canHybrid) {
324
+ return this.denseQuery(vector, topK);
325
+ }
326
+
327
+ const fetchN = Math.min(topK * 2, 100);
328
+ const [denseHits, sparseHits] = await Promise.all([
329
+ this.denseQuery(vector, fetchN),
330
+ this.sparseQuery(vector, queryText!, fetchN),
331
+ ]);
332
+
333
+ return rrfMerge(denseHits, sparseHits, topK);
334
+ }
335
+
336
+ private async denseQuery(
337
+ vector: number[],
338
+ topK: number,
339
+ ): Promise<QueryHit[]> {
340
+ const res = await this.ns().query({ vector, topK, includeMetadata: true });
341
+ return (res.matches ?? []).map(hitFromMatch);
342
+ }
343
+
344
+ /**
345
+ * Sparse-weighted query for hybrid search.
346
+ *
347
+ * Pinecone's dotproduct index ALWAYS requires a non-empty dense `vector`
348
+ * array — even when the call is primarily sparse. Passing `[]` triggers the
349
+ * "You must enter an array of RecordValues" error. We pass the real dense
350
+ * query vector here; Pinecone's scoring blends both dimensions and the RRF
351
+ * merge downstream takes care of the final ranking.
352
+ */
353
+ private async sparseQuery(
354
+ denseVector: number[],
355
+ queryText: string,
356
+ topK: number,
357
+ ): Promise<QueryHit[]> {
358
+ const sparseVector = await this.buildQuerySparseVector(queryText);
359
+ const res = await this.ns().query({
360
+ vector: denseVector,
361
+ sparseVector,
362
+ topK,
363
+ includeMetadata: true,
364
+ });
365
+ return (res.matches ?? []).map(hitFromMatch);
366
+ }
367
+
368
+ // ── Count ──────────────────────────────────────────────────────────────────
369
+
370
+ async count(): Promise<number | null> {
371
+ try {
372
+ const stats = await this.ns().describeIndexStats();
373
+ return (
374
+ stats.namespaces?.[this.namespace]?.recordCount ??
375
+ stats.totalRecordCount ??
376
+ 0
377
+ );
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ // ── Connection test ────────────────────────────────────────────────────────
384
+
385
+ async testConnection(dimensions: number): Promise<void> {
386
+ const pc = this.getClient();
387
+ let indexList;
388
+ try {
389
+ indexList = await pc.listIndexes();
390
+ } catch (err: any) {
391
+ if (
392
+ err.message?.toLowerCase().includes("api key") ||
393
+ err.name === "PineconeAuthorizationError" ||
394
+ err.status === 401
395
+ ) {
396
+ throw new Error("Invalid Pinecone API key.");
397
+ }
398
+ throw new Error(`Failed to connect to Pinecone: ${err.message}`);
399
+ }
400
+
401
+ const indexModel = (indexList.indexes ?? []).find(
402
+ (i) => i.name === this.indexName,
403
+ );
404
+ if (!indexModel) {
405
+ throw new Error(
406
+ `Index "${this.indexName}" does not exist in your Pinecone project. Please create it first.`,
407
+ );
408
+ }
409
+
410
+ if (indexModel.dimension !== dimensions) {
411
+ throw new Error(
412
+ `Dimension mismatch: Index "${this.indexName}" has dimension ${indexModel.dimension}, but the selected embedding model requires dimension ${dimensions}.`,
413
+ );
414
+ }
415
+
416
+ if (this.needsSparse) {
417
+ if (!this.config.sparseModel) {
418
+ throw new Error(
419
+ "Hybrid/lexical search requires a sparse model to be selected.",
420
+ );
421
+ }
422
+
423
+ const metric =
424
+ (indexModel as any).metric ?? (indexModel as any).spec?.metric;
425
+ if (metric && metric !== "dotproduct") {
426
+ throw new Error(
427
+ `Hybrid/lexical requires your Pinecone index to use the "dotproduct" metric, ` +
428
+ `but "${this.indexName}" uses "${metric}". ` +
429
+ `Please delete and recreate the index with metric = dotproduct.`,
430
+ );
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ // ── Helpers ──────────────────────────────────────────────────────────────────
437
+
438
+ function hitFromMatch(m: any): QueryHit {
439
+ const meta = (m.metadata ?? {}) as PineMeta;
440
+ const {
441
+ text,
442
+ title,
443
+ url,
444
+ source,
445
+ documentId,
446
+ chunkIndex,
447
+ ...customMetadata
448
+ } = meta;
449
+
450
+ return {
451
+ id: m.id,
452
+ score: m.score ?? 0,
453
+ text: (text as string) ?? "",
454
+ title: (title as string) ?? "Untitled",
455
+ url: (url as string) || undefined,
456
+ documentId: (documentId as string) ?? "",
457
+ metadata:
458
+ Object.keys(customMetadata).length > 0 ? customMetadata : undefined,
459
+ };
460
+ }
461
+
462
+ function rrfMerge(
463
+ denseHits: QueryHit[],
464
+ sparseHits: QueryHit[],
465
+ topK: number,
466
+ ): QueryHit[] {
467
+ const scores = new Map<string, { hit: QueryHit; score: number }>();
468
+
469
+ const addList = (hits: QueryHit[]) =>
470
+ hits.forEach((hit, rank) => {
471
+ const contrib = 1 / (RRF_K + rank + 1);
472
+ const prev = scores.get(hit.id);
473
+ if (prev) {
474
+ prev.score += contrib;
475
+ } else {
476
+ scores.set(hit.id, { hit, score: contrib });
477
+ }
478
+ });
479
+
480
+ addList(denseHits);
481
+ addList(sparseHits);
482
+
483
+ return [...scores.values()]
484
+ .sort((a, b) => b.score - a.score)
485
+ .slice(0, topK)
486
+ .map(({ hit, score }) => ({ ...hit, score }));
487
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { RagConfig } from "@larkup/core/types";
2
+ import type { VectorStoreAdapter } from "./adapters/base";
3
+
4
+ /**
5
+ * Build the right adapter from the persisted config. Centralizing this keeps
6
+ * the indexer + retrieval store-agnostic — they only ever see the interface.
7
+ *
8
+ * Adapters are imported lazily (dynamic import) so a heavy/native engine like
9
+ * LanceDB is only loaded when it's actually the selected store. This keeps the
10
+ * indexing route from crashing at module-eval time if one engine's native
11
+ * binding isn't available in the current environment.
12
+ */
13
+ export async function createAdapter(
14
+ config: RagConfig,
15
+ onRateLimit?: (waitSecs: number, attempt: number) => void | Promise<void>,
16
+ ): Promise<VectorStoreAdapter> {
17
+ switch (config.vectorStore) {
18
+ case "pinecone": {
19
+ const { PineconeAdapter } = await import("./adapters/pinecone");
20
+ return new PineconeAdapter({
21
+ apiKey: config.storeConfig.apiKey,
22
+ indexName: config.storeConfig.indexName,
23
+ namespace: config.storeConfig.namespace,
24
+ sparseModel: config.storeConfig.sparseModel,
25
+ indexType: config.indexType,
26
+ onRateLimit,
27
+ });
28
+ }
29
+ case "chroma": {
30
+ const { ChromaAdapter } = await import("./adapters/chroma");
31
+ return new ChromaAdapter({
32
+ mode: config.storeConfig.mode,
33
+ host: config.storeConfig.host,
34
+ authToken: config.storeConfig.authToken,
35
+ apiKey: config.storeConfig.apiKey,
36
+ tenant: config.storeConfig.tenant,
37
+ database: config.storeConfig.database,
38
+ collectionName: config.storeConfig.collectionName,
39
+ indexType: config.indexType,
40
+ });
41
+ }
42
+ case "lancedb":
43
+ default: {
44
+ const { LanceDBAdapter } = await import("./adapters/lancedb");
45
+ return new LanceDBAdapter({
46
+ mode: config.storeConfig.mode,
47
+ dbPath: config.storeConfig.dbPath,
48
+ uri: config.storeConfig.uri,
49
+ apiKey: config.storeConfig.apiKey,
50
+ tableName: config.storeConfig.tableName,
51
+ });
52
+ }
53
+ }
54
+
55
+ }