@strav/rag 0.4.31 → 1.0.0-alpha.19

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.
@@ -1,98 +1,322 @@
1
- import { inject, Configuration, ConfigurationError } from '@strav/kernel'
2
- import type { VectorStore } from './vector_store.ts'
3
- import type { RagConfig, StoreConfig, EmbeddingConfig, ChunkingConfig } from './types.ts'
4
- import { NullDriver } from './drivers/null_driver.ts'
1
+ /**
2
+ * `RagManager` the facade apps use for RAG workflows.
3
+ *
4
+ * Three concept clusters:
5
+ *
6
+ * - **Stores.** Apps register vector stores in
7
+ * `config.rag.stores`; the manager constructs them lazily on
8
+ * first `store(name)` call. Custom drivers register via
9
+ * `manager.extend(name, factory)`.
10
+ *
11
+ * - **Ingest.** `manager.ingest(collection, content, opts?)`
12
+ * chunks → embeds → upserts. Returns the vector ids it
13
+ * wrote. Apps that already have chunks bypass the chunker
14
+ * by passing `IngestOptions.chunks`.
15
+ *
16
+ * - **Retrieve.** `manager.retrieve(query, opts?)` embeds the
17
+ * query, runs the store's similarity search, and returns
18
+ * ranked matches. `topK` / `threshold` / `filter` pass
19
+ * through.
20
+ *
21
+ * Multitenancy: invisible. The pgvector driver relies on RLS via
22
+ * `app.tenant_id` session settings, so apps that wrap calls in
23
+ * `tenants.withTenant(...)` get per-tenant isolation for free.
24
+ */
25
+
26
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import for the container path that wires PgvectorDriver.
27
+ import { PostgresDatabase } from '@strav/database'
28
+ // biome-ignore lint/style/useImportType: BrainManager value import for @inject() param-type metadata.
29
+ import { BrainManager } from '@strav/brain'
30
+ // biome-ignore lint/style/useImportType: Application value import for the container handle.
31
+ import { Application, inject } from '@strav/kernel'
32
+ import { createChunker } from './chunking/chunker.ts'
5
33
  import { MemoryDriver } from './drivers/memory_driver.ts'
6
34
  import { PgvectorDriver } from './drivers/pgvector_driver.ts'
35
+ import { EmbeddingError, RagError } from './rag_error.ts'
36
+ import type {
37
+ ChunkingConfig,
38
+ Chunk,
39
+ Chunker,
40
+ RagConfig,
41
+ RetrieveOptions,
42
+ RetrieveResult,
43
+ RetrievedDocument,
44
+ StoreConfig,
45
+ VectorDocument,
46
+ } from './types.ts'
47
+ import type { VectorStore } from './vector_store.ts'
7
48
 
8
- @inject
9
- export default class RagManager {
10
- private static _config: RagConfig
11
- private static _stores = new Map<string, VectorStore>()
12
- private static _extensions = new Map<string, (config: StoreConfig) => VectorStore>()
13
-
14
- constructor(config: Configuration) {
15
- RagManager._config = {
16
- default: config.get('rag.default', 'null') as string,
17
- prefix: config.get('rag.prefix', '') as string,
18
- embedding: config.get('rag.embedding', {
19
- provider: 'openai',
20
- model: 'text-embedding-3-small',
21
- dimension: 1536,
22
- }) as EmbeddingConfig,
23
- chunking: config.get('rag.chunking', {
24
- strategy: 'recursive',
25
- chunkSize: 512,
26
- overlap: 64,
27
- }) as ChunkingConfig,
28
- stores: config.get('rag.stores', {}) as Record<string, StoreConfig>,
29
- }
30
- }
49
+ export interface IngestOptions {
50
+ /**
51
+ * Override the store. Defaults to the manager's default store.
52
+ */
53
+ store?: string
54
+ /** App-defined pointer back to the source row this content came from. */
55
+ sourceId?: string
56
+ /** Metadata attached to every chunk. Combined with per-chunk metadata if `chunks` is supplied. */
57
+ metadata?: Record<string, unknown>
58
+ /** Override chunking strategy + size for this call. */
59
+ chunking?: Partial<ChunkingConfig>
60
+ /**
61
+ * Pre-chunked content — skips the chunker entirely. The chunker
62
+ * helpers (`startOffset`, `endOffset`, `index`) carry through
63
+ * as metadata.
64
+ */
65
+ chunks?: readonly Chunk[]
66
+ /**
67
+ * Optional per-chunk sanitizer applied AFTER chunking, BEFORE
68
+ * embedding. Return `null` to drop the chunk; otherwise return
69
+ * the (possibly modified) text. Use to scrub PII / secrets /
70
+ * prompt-injection markers from untrusted source content.
71
+ */
72
+ sanitize?(chunk: { content: string; index: number }): string | null | Promise<string | null>
73
+ /** Override the brain provider used for the embedding call. */
74
+ embedProvider?: string
75
+ /** Override the embedding model. */
76
+ embedModel?: string
77
+ }
78
+
79
+ export interface RagManagerOptions {
80
+ config: RagConfig
81
+ brain: BrainManager
82
+ /** Optional — required only if pgvector stores are configured. */
83
+ db?: PostgresDatabase
84
+ }
85
+
86
+ /** Factory for custom drivers — apps register via `manager.extend(name, factory)`. */
87
+ export type StoreFactory = (config: StoreConfig) => VectorStore
88
+
89
+ @inject()
90
+ export class RagManager {
91
+ readonly config: RagConfig
92
+ private readonly brain: BrainManager
93
+ private readonly db: PostgresDatabase | undefined
94
+ private readonly stores = new Map<string, VectorStore>()
95
+ private readonly extensions = new Map<string, StoreFactory>()
31
96
 
32
- static get config(): RagConfig {
33
- if (!RagManager._config) {
34
- throw new ConfigurationError(
35
- 'RagManager not configured. Resolve it through the container first.'
97
+ constructor(options: RagManagerOptions) {
98
+ if (!options.config.stores[options.config.default]) {
99
+ throw new RagError(
100
+ `RagManager: default store "${options.config.default}" is not configured.`,
101
+ {
102
+ context: {
103
+ default: options.config.default,
104
+ available: Object.keys(options.config.stores),
105
+ },
106
+ },
36
107
  )
37
108
  }
38
- return RagManager._config
109
+ this.config = options.config
110
+ this.brain = options.brain
111
+ this.db = options.db
39
112
  }
40
113
 
41
- static store(name?: string): VectorStore {
42
- const key = name ?? RagManager.config.default
114
+ // ─── Store management ─────────────────────────────────────────────────
43
115
 
44
- let store = RagManager._stores.get(key)
45
- if (store) return store
46
-
47
- const storeConfig = RagManager.config.stores[key]
48
- if (!storeConfig) {
49
- throw new ConfigurationError(`RAG store "${key}" is not configured.`)
116
+ /**
117
+ * Resolve a vector store by name (or the default when omitted).
118
+ * Stores are constructed lazily on first use + memoized.
119
+ */
120
+ store(name?: string): VectorStore {
121
+ const key = name ?? this.config.default
122
+ const cached = this.stores.get(key)
123
+ if (cached) return cached
124
+ const cfg = this.config.stores[key]
125
+ if (!cfg) {
126
+ throw new RagError(`RagManager: store "${key}" is not configured.`, {
127
+ context: { requested: key, available: Object.keys(this.config.stores) },
128
+ })
50
129
  }
51
-
52
- store = RagManager.createStore(key, storeConfig)
53
- RagManager._stores.set(key, store)
130
+ const store = this.createStore(cfg)
131
+ this.stores.set(key, store)
54
132
  return store
55
133
  }
56
134
 
57
- static get prefix(): string {
58
- return RagManager._config?.prefix ?? ''
135
+ /** Register a custom driver. Subsequent `store(...)` calls can resolve `driver: <name>`. */
136
+ extend(name: string, factory: StoreFactory): void {
137
+ this.extensions.set(name, factory)
59
138
  }
60
139
 
61
- static collectionName(name: string): string {
62
- return RagManager.prefix ? `${RagManager.prefix}${name}` : name
140
+ /** Hand-wire a store instance under a name (tests / one-off drivers). */
141
+ useStore(name: string, store: VectorStore): void {
142
+ this.stores.set(name, store)
63
143
  }
64
144
 
65
- static extend(name: string, factory: (config: StoreConfig) => VectorStore): void {
66
- RagManager._extensions.set(name, factory)
145
+ /**
146
+ * Compose the configured prefix with `name` — apps that want to
147
+ * namespace collections (per-tenant, per-app) set `config.rag.prefix`
148
+ * and call `manager.collectionName(...)` to resolve at runtime.
149
+ */
150
+ collectionName(name: string): string {
151
+ return this.config.prefix ? `${this.config.prefix}${name}` : name
67
152
  }
68
153
 
69
- static useStore(store: VectorStore): void {
70
- RagManager._stores.set(store.name, store)
154
+ // ─── Ingest ───────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Chunk → embed → upsert. Returns the vector ids it wrote. The
158
+ * caller-supplied `collection` is composed with the configured
159
+ * `prefix` before hitting the store.
160
+ */
161
+ async ingest(
162
+ collection: string,
163
+ content: string,
164
+ options: IngestOptions = {},
165
+ ): Promise<string[]> {
166
+ const fullCollection = this.collectionName(collection)
167
+ const chunkerConfig: ChunkingConfig = {
168
+ strategy: options.chunking?.strategy ?? this.config.chunking.strategy,
169
+ chunkSize: options.chunking?.chunkSize ?? this.config.chunking.chunkSize,
170
+ overlap: options.chunking?.overlap ?? this.config.chunking.overlap,
171
+ ...(options.chunking?.separators ?? this.config.chunking.separators
172
+ ? { separators: options.chunking?.separators ?? this.config.chunking.separators }
173
+ : {}),
174
+ }
175
+
176
+ let chunks: Chunk[] = options.chunks
177
+ ? [...options.chunks]
178
+ : this.chunker(chunkerConfig).chunk(content)
179
+ if (chunks.length === 0) return []
180
+
181
+ if (options.sanitize) {
182
+ const filtered: Chunk[] = []
183
+ for (const chunk of chunks) {
184
+ const next = await options.sanitize({ content: chunk.content, index: chunk.index })
185
+ if (next === null) continue
186
+ filtered.push({ ...chunk, content: next })
187
+ }
188
+ chunks = filtered
189
+ if (chunks.length === 0) return []
190
+ }
191
+
192
+ const texts = chunks.map((c) => c.content)
193
+ let embeddings: number[][]
194
+ try {
195
+ const result = await this.brain.embed(texts, {
196
+ provider: options.embedProvider ?? this.config.embedding.provider,
197
+ model: options.embedModel ?? this.config.embedding.model,
198
+ })
199
+ embeddings = result.embeddings as number[][]
200
+ } catch (cause) {
201
+ throw new EmbeddingError(
202
+ `RagManager.ingest: embedding ${texts.length} chunks failed.`,
203
+ { context: { collection: fullCollection }, cause },
204
+ )
205
+ }
206
+
207
+ const baseId = crypto.randomUUID()
208
+ const documents: VectorDocument[] = chunks.map((chunk, i) => ({
209
+ id: `${baseId}_${i}`,
210
+ ...(options.sourceId !== undefined ? { sourceId: options.sourceId } : {}),
211
+ content: chunk.content,
212
+ embedding: embeddings[i]!,
213
+ metadata: {
214
+ ...(options.metadata ?? {}),
215
+ chunkIndex: chunk.index,
216
+ startOffset: chunk.startOffset,
217
+ endOffset: chunk.endOffset,
218
+ },
219
+ }))
220
+
221
+ await this.store(options.store).upsert(fullCollection, documents)
222
+ return documents.map((d) => d.id as string)
223
+ }
224
+
225
+ // ─── Retrieve ─────────────────────────────────────────────────────────
226
+
227
+ async retrieve(
228
+ query: string,
229
+ options: RetrieveOptions = {},
230
+ ): Promise<RetrieveResult> {
231
+ const fullCollection = this.collectionName(
232
+ options.collection ?? this.config.default,
233
+ )
234
+ const start = performance.now()
235
+
236
+ let embedding: number[]
237
+ try {
238
+ const result = await this.brain.embed([query], {
239
+ provider: options.embedProvider ?? this.config.embedding.provider,
240
+ model: options.embedModel ?? this.config.embedding.model,
241
+ })
242
+ embedding = result.embeddings[0] as number[]
243
+ } catch (cause) {
244
+ throw new EmbeddingError(
245
+ `RagManager.retrieve: embedding query failed.`,
246
+ { context: { collection: fullCollection }, cause },
247
+ )
248
+ }
249
+
250
+ const queryOpts: { topK?: number; threshold?: number; filter?: Record<string, unknown> } = {}
251
+ if (options.topK !== undefined) queryOpts.topK = options.topK
252
+ if (options.threshold !== undefined) queryOpts.threshold = options.threshold
253
+ if (options.filter !== undefined) queryOpts.filter = options.filter
254
+
255
+ const result = await this.store(options.store).query(
256
+ fullCollection,
257
+ embedding,
258
+ queryOpts,
259
+ )
260
+
261
+ const matches: RetrievedDocument[] = result.matches.map((m) => ({
262
+ id: m.id,
263
+ content: m.content,
264
+ score: m.score,
265
+ similarity: m.score,
266
+ metadata: m.metadata,
267
+ ...(m.sourceId !== undefined ? { sourceId: m.sourceId } : {}),
268
+ }))
269
+
270
+ return {
271
+ query,
272
+ matches,
273
+ processingTimeMs: performance.now() - start,
274
+ }
71
275
  }
72
276
 
73
- static reset(): void {
74
- RagManager._stores.clear()
75
- RagManager._extensions.clear()
76
- RagManager._config = undefined as any
277
+ /**
278
+ * Create a collection on the active (or named) store. For
279
+ * pgvector this is a no-op — every collection lives in the
280
+ * same table. For MemoryDriver this allocates the bucket.
281
+ * Apps call it once at boot or before the first ingest of a
282
+ * new collection name.
283
+ */
284
+ async createCollection(
285
+ collection: string,
286
+ options: { store?: string; dimension?: number } = {},
287
+ ): Promise<void> {
288
+ const fullCollection = this.collectionName(collection)
289
+ const dimension = options.dimension ?? this.config.embedding.dimension
290
+ await this.store(options.store).createCollection(fullCollection, dimension)
77
291
  }
78
292
 
79
- private static createStore(name: string, config: StoreConfig): VectorStore {
80
- const driverName = config.driver ?? name
293
+ // ─── Internals ────────────────────────────────────────────────────────
81
294
 
82
- const extension = RagManager._extensions.get(driverName)
83
- if (extension) return extension(config)
295
+ private chunker(config: ChunkingConfig): Chunker {
296
+ return createChunker(config)
297
+ }
84
298
 
85
- switch (driverName) {
86
- case 'pgvector':
87
- return new PgvectorDriver(config)
299
+ private createStore(config: StoreConfig): VectorStore {
300
+ const ext = this.extensions.get(config.driver)
301
+ if (ext) return ext(config)
302
+ switch (config.driver) {
88
303
  case 'memory':
89
304
  return new MemoryDriver()
90
- case 'null':
91
- return new NullDriver()
305
+ case 'pgvector':
306
+ if (!this.db) {
307
+ throw new RagError(
308
+ 'RagManager: pgvector driver requires a PostgresDatabase. Register DatabaseProvider before RagProvider, or pass `db` to the manager constructor.',
309
+ )
310
+ }
311
+ return PgvectorDriver.fromConfig(this.db, config)
92
312
  default:
93
- throw new ConfigurationError(
94
- `Unknown RAG driver "${driverName}". Register it with RagManager.extend().`
313
+ throw new RagError(
314
+ `RagManager: unknown driver "${config.driver}". Register it via \`manager.extend(...)\`.`,
315
+ { context: { driver: config.driver } },
95
316
  )
96
317
  }
97
318
  }
98
319
  }
320
+
321
+ /** Public alias for the container-resolution helper apps occasionally pass around. */
322
+ export type RagManagerResolver = (app: Application) => RagManager
@@ -1,16 +1,94 @@
1
- import { ServiceProvider } from '@strav/kernel'
2
- import type { Application } from '@strav/kernel'
3
- import RagManager from './rag_manager.ts'
1
+ /**
2
+ * `RagProvider` `ServiceProvider` that wires `RagManager` into
3
+ * the container from `config.rag`.
4
+ *
5
+ * Eager construction at boot — a malformed config or a missing
6
+ * pgvector dependency should fail before the first call hits.
7
+ * Apps register `BrainProvider` and `DatabaseProvider` (when
8
+ * pgvector is in the store list) before this one; the
9
+ * `dependencies` array makes the order explicit.
10
+ *
11
+ * Config defaults: if `config.rag` is absent entirely, the
12
+ * provider boots a sensible in-memory setup so apps can try
13
+ * `rag.ingest()` / `rag.retrieve()` in dev without configuration
14
+ * — the memory driver is registered as the default store and a
15
+ * `recursive` chunker is configured. Production apps override
16
+ * via a real `config/rag.ts`.
17
+ */
4
18
 
5
- export default class RagProvider extends ServiceProvider {
6
- readonly name = 'rag'
7
- override readonly dependencies = ['config']
19
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import — required when any pgvector store is configured. Loaded conditionally below.
20
+ import { PostgresDatabase } from '@strav/database'
21
+ // biome-ignore lint/style/useImportType: BrainManager value import for c.resolve.
22
+ import { BrainManager } from '@strav/brain'
23
+ import {
24
+ type Application,
25
+ ConfigError,
26
+ ConfigRepository,
27
+ ServiceProvider,
28
+ } from '@strav/kernel'
29
+ import { RagManager, type RagManagerOptions } from './rag_manager.ts'
30
+ import type { RagConfig } from './types.ts'
31
+
32
+ export class RagProvider extends ServiceProvider {
33
+ override readonly name = 'rag'
34
+ override readonly dependencies = ['config', 'brain']
8
35
 
9
36
  override register(app: Application): void {
10
- app.singleton(RagManager)
37
+ app.singleton(RagManager, (c) => {
38
+ const raw = c.resolve(ConfigRepository).get('rag') as Partial<RagConfig> | undefined
39
+ const config = applyDefaults(raw)
40
+
41
+ const brain = c.resolve(BrainManager)
42
+ const opts: RagManagerOptions = { config, brain }
43
+
44
+ // Only resolve the database when at least one store needs it.
45
+ const needsDb = Object.values(config.stores).some((s) => s.driver === 'pgvector')
46
+ if (needsDb) {
47
+ try {
48
+ opts.db = c.resolve(PostgresDatabase)
49
+ } catch (cause) {
50
+ throw new ConfigError(
51
+ 'RagProvider: at least one store uses `driver: "pgvector"` but PostgresDatabase is not registered. Register DatabaseProvider before RagProvider.',
52
+ { cause },
53
+ )
54
+ }
55
+ }
56
+ return new RagManager(opts)
57
+ })
11
58
  }
12
59
 
13
60
  override boot(app: Application): void {
61
+ // Force-resolve so config errors surface at boot, not on first call.
14
62
  app.resolve(RagManager)
15
63
  }
16
64
  }
65
+
66
+ /**
67
+ * Fill in defaults for omitted config fields. Apps with no
68
+ * `config/rag.ts` at all get a working in-memory setup.
69
+ */
70
+ function applyDefaults(raw: Partial<RagConfig> | undefined): RagConfig {
71
+ const config: Partial<RagConfig> = raw ?? {}
72
+ const stores = config.stores ?? { memory: { driver: 'memory' } }
73
+ const def = config.default ?? Object.keys(stores)[0] ?? 'memory'
74
+ if (!stores[def]) {
75
+ throw new ConfigError(
76
+ `RagProvider: default store "${def}" is not declared in config.rag.stores.`,
77
+ )
78
+ }
79
+ return {
80
+ default: def,
81
+ ...(config.prefix !== undefined ? { prefix: config.prefix } : {}),
82
+ embedding: config.embedding ?? {
83
+ provider: 'openai',
84
+ model: 'text-embedding-3-small',
85
+ dimension: 1536,
86
+ },
87
+ chunking: config.chunking ?? {
88
+ strategy: 'recursive',
89
+ chunkSize: 512,
90
+ overlap: 64,
91
+ },
92
+ stores,
93
+ }
94
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `ragVectorSchema` — the framework-known shape of the vectors
3
+ * table. Carries every column EXCEPT `embedding`, which lives
4
+ * outside the framework schema system because pgvector's
5
+ * `vector(N)` type isn't expressible via `defineSchema`. The
6
+ * `applyRagVectorMigration` helper attaches the embedding column
7
+ * + HNSW index in the migration step.
8
+ *
9
+ * Columns the framework manages:
10
+ *
11
+ * - `id` ULID primary key.
12
+ * - `tenant_id` Auto-injected by `tenanted: true`. RLS policies
13
+ * scope reads + writes by
14
+ * `current_setting('app.tenant_id')` so apps that
15
+ * wrap calls in `tenants.withTenant(...)` get
16
+ * per-tenant isolation for free.
17
+ * - `collection` Logical bucket — apps create one collection per
18
+ * conceptual corpus (`articles`, `support_docs`,
19
+ * per-user notebooks, ...).
20
+ * - `source_id` Optional pointer back to the source row a chunk
21
+ * came from. `deleteBySource(collection, id)`
22
+ * drops every chunk for one source in a single
23
+ * DELETE — handy when re-indexing on update.
24
+ * - `content` The chunk text. Plain `text` for full storage.
25
+ * - `metadata` Free-form JSONB. Indexable in the recommended
26
+ * migration via a GIN index when apps query by
27
+ * metadata filters.
28
+ * - `created_at` Insert timestamp. Useful for audit + soft
29
+ * recency filtering.
30
+ *
31
+ * Columns attached by `applyRagVectorMigration`:
32
+ *
33
+ * - `embedding vector(<dimension>) NOT NULL` — the vector itself.
34
+ * - `HNSW idx_<table>_embedding` on `(embedding vector_cosine_ops)`.
35
+ *
36
+ * Apps register the schema with `SchemaRegistry` at boot (mirrors
37
+ * every other framework schema), then call
38
+ * `applyRagVectorMigration(db, { dimension, registry })` inside
39
+ * the migration's `up()`.
40
+ */
41
+
42
+ import { Archetype, defineSchema } from '@strav/database'
43
+
44
+ export const ragVectorSchema = defineSchema(
45
+ 'rag_vector',
46
+ Archetype.Entity,
47
+ (t) => {
48
+ t.id()
49
+ t.string('collection').max(128).notNull()
50
+ t.string('source_id').max(128).nullable()
51
+ t.text('content').notNull()
52
+ t.json('metadata').notNull().default({})
53
+ t.timestamp('created_at').notNull()
54
+ },
55
+ { tenanted: true },
56
+ )