@opensaas/stack-rag 0.1.6 → 0.1.7
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +9 -0
- package/README.md +82 -6
- package/dist/config/plugin.d.ts.map +1 -1
- package/dist/config/plugin.js +29 -1
- package/dist/config/plugin.js.map +1 -1
- package/dist/config/types.d.ts +51 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +1 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/searchable.d.ts +42 -0
- package/dist/fields/searchable.d.ts.map +1 -0
- package/dist/fields/searchable.js +51 -0
- package/dist/fields/searchable.js.map +1 -0
- package/dist/fields/searchable.test.d.ts +2 -0
- package/dist/fields/searchable.test.d.ts.map +1 -0
- package/dist/fields/searchable.test.js +112 -0
- package/dist/fields/searchable.test.js.map +1 -0
- package/dist/providers/openai.d.ts +2 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +35 -20
- package/dist/providers/openai.js.map +1 -1
- package/dist/runtime/batch.test.js +1 -1
- package/dist/storage/access-filter.d.ts +30 -0
- package/dist/storage/access-filter.d.ts.map +1 -0
- package/dist/storage/access-filter.js +241 -0
- package/dist/storage/access-filter.js.map +1 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/pgvector.d.ts.map +1 -1
- package/dist/storage/pgvector.js +26 -11
- package/dist/storage/pgvector.js.map +1 -1
- package/dist/storage/storage.test.js +1 -0
- package/dist/storage/storage.test.js.map +1 -1
- package/dist/storage/types.d.ts +5 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js.map +1 -1
- package/package.json +3 -3
- package/src/config/plugin.ts +35 -2
- package/src/config/types.ts +59 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/searchable.test.ts +136 -0
- package/src/fields/searchable.ts +57 -0
- package/src/providers/openai.ts +37 -22
- package/src/runtime/batch.test.ts +1 -1
- package/src/storage/access-filter.ts +303 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/pgvector.ts +31 -11
- package/src/storage/storage.test.ts +1 -0
- package/src/storage/types.ts +6 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/storage/types.d.ts
CHANGED
|
@@ -53,6 +53,11 @@ export type SearchOptions = {
|
|
|
53
53
|
* This is merged with access control filters
|
|
54
54
|
*/
|
|
55
55
|
where?: Record<string, unknown>;
|
|
56
|
+
/**
|
|
57
|
+
* OpenSaaS config for access control integration
|
|
58
|
+
* Required to properly enforce access control in raw SQL queries
|
|
59
|
+
*/
|
|
60
|
+
config?: import('@opensaas/stack-core').OpenSaasConfig;
|
|
56
61
|
};
|
|
57
62
|
/**
|
|
58
63
|
* Distance functions for vector similarity
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEtD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;;OAQG;IACH,MAAM,CAAC,CAAC,GAAG,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAE7B;;;;;;;OAOG;IACH,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAAA;CACnD;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;OAGG;IACH,OAAO,EAAE,OAAO,sBAAsB,EAAE,aAAa,CAAA;IAErD;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEtD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;;OAQG;IACH,MAAM,CAAC,CAAC,GAAG,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAE7B;;;;;;;OAOG;IACH,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAAA;CACnD;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;OAGG;IACH,OAAO,EAAE,OAAO,sBAAsB,EAAE,aAAa,CAAA;IAErD;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAE/B;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,sBAAsB,EAAE,cAAc,CAAA;CACvD,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,IAAI,GAAG,eAAe,CAAA;AAEhE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAI1D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAK3D;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAS3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAajE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAgFA;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,MAAgB;IAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC,CAAA;IAC5E,IAAI,SAAS,KAAK,CAAC;QAAE,OAAO,MAAM,CAAA;IAClC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,SAAS,CAAC,CAAA;AAC7C,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;AACvD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,UAAU,CAAC,CAAW,EAAE,CAAW;IACjD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,MAAM,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAA;IAC1E,CAAC;IACD,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE;QAC9C,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QACvB,OAAO,GAAG,GAAG,IAAI,GAAG,IAAI,CAAA;IAC1B,CAAC,EAAE,CAAC,CAAC,CAAA;IACL,OAAO,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,CAAW,EAAE,CAAW;IACvD,MAAM,OAAO,GAAG,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IAChC,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAE9C,IAAI,UAAU,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,CAAA;IACV,CAAC;IAED,wCAAwC;IACxC,sCAAsC;IACtC,MAAM,UAAU,GAAG,OAAO,GAAG,CAAC,UAAU,GAAG,UAAU,CAAC,CAAA;IACtD,OAAO,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,CAAC,CAAA;AAC7B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-rag",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "RAG and AI embeddings integration for OpenSaas Stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"openai": "^6.8.0",
|
|
55
|
-
"@opensaas/stack-core": "0.1.
|
|
55
|
+
"@opensaas/stack-core": "0.1.7"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"openai": {
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"openai": "^6.0.0",
|
|
70
70
|
"typescript": "^5.9.3",
|
|
71
71
|
"vitest": "^4.0.0",
|
|
72
|
-
"@opensaas/stack-core": "0.1.
|
|
72
|
+
"@opensaas/stack-core": "0.1.7"
|
|
73
73
|
},
|
|
74
74
|
"scripts": {
|
|
75
75
|
"build": "tsc",
|
package/src/config/plugin.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from '@opensaas/stack-core'
|
|
2
|
-
import type { RAGConfig, NormalizedRAGConfig } from './types.js'
|
|
2
|
+
import type { RAGConfig, NormalizedRAGConfig, SearchableMetadata } from './types.js'
|
|
3
3
|
import { normalizeRAGConfig } from './index.js'
|
|
4
4
|
import { createEmbeddingProvider } from '../providers/index.js'
|
|
5
|
+
import { embedding } from '../fields/embedding.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* RAG plugin for OpenSaas Stack
|
|
@@ -45,7 +46,39 @@ export function ragPlugin(config: RAGConfig): Plugin {
|
|
|
45
46
|
version: '0.1.0',
|
|
46
47
|
|
|
47
48
|
init: async (context) => {
|
|
48
|
-
//
|
|
49
|
+
// First pass: Scan for searchable() wrapped fields and inject embedding fields
|
|
50
|
+
for (const [listName, listConfig] of Object.entries(context.config.lists)) {
|
|
51
|
+
const embeddingFieldsToInject: Record<string, ReturnType<typeof embedding>> = {}
|
|
52
|
+
|
|
53
|
+
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
54
|
+
// Check if field has _searchable metadata
|
|
55
|
+
if ('_searchable' in fieldConfig) {
|
|
56
|
+
const meta = fieldConfig._searchable as SearchableMetadata
|
|
57
|
+
|
|
58
|
+
// Determine embedding field name
|
|
59
|
+
const embeddingName = meta.embeddingFieldName || `${fieldName}Embedding`
|
|
60
|
+
|
|
61
|
+
// Create embedding field
|
|
62
|
+
embeddingFieldsToInject[embeddingName] = embedding({
|
|
63
|
+
sourceField: fieldName,
|
|
64
|
+
provider: meta.provider,
|
|
65
|
+
dimensions: meta.dimensions,
|
|
66
|
+
chunking: meta.chunking,
|
|
67
|
+
autoGenerate: true,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Inject all embedding fields at once
|
|
73
|
+
if (Object.keys(embeddingFieldsToInject).length > 0) {
|
|
74
|
+
context.extendList(listName, {
|
|
75
|
+
fields: embeddingFieldsToInject,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Second pass: Find all embedding fields with autoGenerate enabled
|
|
81
|
+
// This includes both manually defined embedding fields AND injected ones from searchable()
|
|
49
82
|
for (const [listName, listConfig] of Object.entries(context.config.lists)) {
|
|
50
83
|
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
51
84
|
if (
|
package/src/config/types.ts
CHANGED
|
@@ -281,3 +281,62 @@ export type SearchResult<T = unknown> = {
|
|
|
281
281
|
*/
|
|
282
282
|
distance: number
|
|
283
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Options for searchable() field wrapper
|
|
287
|
+
* Simplified options for common use cases
|
|
288
|
+
*/
|
|
289
|
+
export type SearchableOptions = {
|
|
290
|
+
/**
|
|
291
|
+
* Embedding provider to use
|
|
292
|
+
* References a provider name from RAG config
|
|
293
|
+
* Falls back to default provider if not specified
|
|
294
|
+
*/
|
|
295
|
+
provider?: EmbeddingProviderName
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Vector dimensions
|
|
299
|
+
* Must match the provider's output dimensions
|
|
300
|
+
* @default 1536 (OpenAI text-embedding-3-small)
|
|
301
|
+
*/
|
|
302
|
+
dimensions?: number
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Chunking configuration for long texts
|
|
306
|
+
*/
|
|
307
|
+
chunking?: ChunkingConfig
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Custom name for the generated embedding field
|
|
311
|
+
* If not provided, defaults to `${fieldName}Embedding`
|
|
312
|
+
* @example 'contentVector' instead of 'contentEmbedding'
|
|
313
|
+
*/
|
|
314
|
+
embeddingFieldName?: string
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Internal metadata attached to searchable fields
|
|
319
|
+
* Used by ragPlugin to identify and inject embedding fields
|
|
320
|
+
* @internal
|
|
321
|
+
*/
|
|
322
|
+
export type SearchableMetadata = {
|
|
323
|
+
/**
|
|
324
|
+
* Name for the generated embedding field
|
|
325
|
+
*/
|
|
326
|
+
embeddingFieldName: string
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Embedding provider to use
|
|
330
|
+
*/
|
|
331
|
+
provider?: EmbeddingProviderName
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Vector dimensions
|
|
335
|
+
*/
|
|
336
|
+
dimensions?: number
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Chunking configuration
|
|
340
|
+
*/
|
|
341
|
+
chunking?: ChunkingConfig
|
|
342
|
+
}
|
package/src/fields/index.ts
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { searchable } from './searchable.js'
|
|
3
|
+
import type { BaseFieldConfig } from '@opensaas/stack-core'
|
|
4
|
+
import type { SearchableOptions } from '../config/types.js'
|
|
5
|
+
|
|
6
|
+
// Mock text field for testing
|
|
7
|
+
function mockTextField(): BaseFieldConfig {
|
|
8
|
+
return {
|
|
9
|
+
type: 'text',
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
getZodSchema: () => null as any,
|
|
12
|
+
getPrismaType: () => ({ type: 'String', modifiers: '' }),
|
|
13
|
+
getTypeScriptType: () => ({ type: 'string', optional: false }),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('searchable() field wrapper', () => {
|
|
18
|
+
it('should preserve original field properties', () => {
|
|
19
|
+
const field = mockTextField()
|
|
20
|
+
const wrapped = searchable(field)
|
|
21
|
+
|
|
22
|
+
expect(wrapped.type).toBe('text')
|
|
23
|
+
expect(wrapped.getZodSchema).toBe(field.getZodSchema)
|
|
24
|
+
expect(wrapped.getPrismaType).toBe(field.getPrismaType)
|
|
25
|
+
expect(wrapped.getTypeScriptType).toBe(field.getTypeScriptType)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should attach _searchable metadata', () => {
|
|
29
|
+
const field = mockTextField()
|
|
30
|
+
const wrapped = searchable(field, { provider: 'openai' })
|
|
31
|
+
|
|
32
|
+
expect(wrapped._searchable).toBeDefined()
|
|
33
|
+
expect(wrapped._searchable.provider).toBe('openai')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should use default options when not provided', () => {
|
|
37
|
+
const field = mockTextField()
|
|
38
|
+
const wrapped = searchable(field)
|
|
39
|
+
|
|
40
|
+
expect(wrapped._searchable).toBeDefined()
|
|
41
|
+
expect(wrapped._searchable.embeddingFieldName).toBe('')
|
|
42
|
+
expect(wrapped._searchable.provider).toBeUndefined()
|
|
43
|
+
expect(wrapped._searchable.dimensions).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should accept all searchable options', () => {
|
|
47
|
+
const field = mockTextField()
|
|
48
|
+
const options: SearchableOptions = {
|
|
49
|
+
provider: 'ollama',
|
|
50
|
+
dimensions: 768,
|
|
51
|
+
chunking: {
|
|
52
|
+
strategy: 'sentence',
|
|
53
|
+
maxTokens: 300,
|
|
54
|
+
overlap: 25,
|
|
55
|
+
},
|
|
56
|
+
embeddingFieldName: 'customEmbedding',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const wrapped = searchable(field, options)
|
|
60
|
+
|
|
61
|
+
expect(wrapped._searchable.provider).toBe('ollama')
|
|
62
|
+
expect(wrapped._searchable.dimensions).toBe(768)
|
|
63
|
+
expect(wrapped._searchable.chunking).toEqual({
|
|
64
|
+
strategy: 'sentence',
|
|
65
|
+
maxTokens: 300,
|
|
66
|
+
overlap: 25,
|
|
67
|
+
})
|
|
68
|
+
expect(wrapped._searchable.embeddingFieldName).toBe('customEmbedding')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should preserve field with validation options', () => {
|
|
72
|
+
const fieldWithValidation = {
|
|
73
|
+
...mockTextField(),
|
|
74
|
+
validation: {
|
|
75
|
+
isRequired: true,
|
|
76
|
+
length: { min: 10, max: 1000 },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const wrapped = searchable(fieldWithValidation, { provider: 'openai' })
|
|
81
|
+
|
|
82
|
+
expect(wrapped.validation).toEqual({
|
|
83
|
+
isRequired: true,
|
|
84
|
+
length: { min: 10, max: 1000 },
|
|
85
|
+
})
|
|
86
|
+
expect(wrapped._searchable).toBeDefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should preserve field with hooks', () => {
|
|
90
|
+
const resolveInputHook = () => {}
|
|
91
|
+
const fieldWithHooks = {
|
|
92
|
+
...mockTextField(),
|
|
93
|
+
hooks: {
|
|
94
|
+
resolveInput: resolveInputHook,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const wrapped = searchable(fieldWithHooks)
|
|
99
|
+
|
|
100
|
+
expect(wrapped.hooks).toBeDefined()
|
|
101
|
+
expect(wrapped.hooks?.resolveInput).toBe(resolveInputHook)
|
|
102
|
+
expect(wrapped._searchable).toBeDefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should work with different field types', () => {
|
|
106
|
+
const richTextField = {
|
|
107
|
+
...mockTextField(),
|
|
108
|
+
type: 'richText' as const,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const wrapped = searchable(richTextField, { provider: 'openai' })
|
|
112
|
+
|
|
113
|
+
expect(wrapped.type).toBe('richText')
|
|
114
|
+
expect(wrapped._searchable).toBeDefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should handle empty embeddingFieldName option', () => {
|
|
118
|
+
const field = mockTextField()
|
|
119
|
+
const wrapped = searchable(field, { embeddingFieldName: '' })
|
|
120
|
+
|
|
121
|
+
expect(wrapped._searchable.embeddingFieldName).toBe('')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should handle partial chunking config', () => {
|
|
125
|
+
const field = mockTextField()
|
|
126
|
+
const wrapped = searchable(field, {
|
|
127
|
+
chunking: {
|
|
128
|
+
strategy: 'recursive',
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(wrapped._searchable.chunking).toEqual({
|
|
133
|
+
strategy: 'recursive',
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { BaseFieldConfig } from '@opensaas/stack-core'
|
|
2
|
+
import type { SearchableOptions, SearchableMetadata } from '../config/types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* High-level field wrapper that automatically adds embedding field and hooks
|
|
6
|
+
*
|
|
7
|
+
* This wrapper makes it easy to add semantic search to any text field by
|
|
8
|
+
* automatically creating a companion embedding field that stays in sync.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { text } from '@opensaas/stack-core/fields'
|
|
13
|
+
* import { searchable } from '@opensaas/stack-rag/fields'
|
|
14
|
+
*
|
|
15
|
+
* fields: {
|
|
16
|
+
* content: searchable(text(), {
|
|
17
|
+
* provider: 'openai',
|
|
18
|
+
* dimensions: 1536
|
|
19
|
+
* })
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* This is equivalent to the manual pattern:
|
|
24
|
+
* ```typescript
|
|
25
|
+
* fields: {
|
|
26
|
+
* content: text(),
|
|
27
|
+
* contentEmbedding: embedding({
|
|
28
|
+
* sourceField: 'content',
|
|
29
|
+
* provider: 'openai',
|
|
30
|
+
* dimensions: 1536,
|
|
31
|
+
* autoGenerate: true
|
|
32
|
+
* })
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @param field - The field to make searchable (usually text() or richText())
|
|
37
|
+
* @param options - Embedding configuration options
|
|
38
|
+
* @returns The same field with searchable metadata attached
|
|
39
|
+
*/
|
|
40
|
+
export function searchable<T extends BaseFieldConfig>(
|
|
41
|
+
field: T,
|
|
42
|
+
options: SearchableOptions = {},
|
|
43
|
+
): T & { _searchable: SearchableMetadata } {
|
|
44
|
+
const { embeddingFieldName, provider, dimensions, chunking } = options
|
|
45
|
+
|
|
46
|
+
// Attach metadata to the field for ragPlugin to detect
|
|
47
|
+
return {
|
|
48
|
+
...field,
|
|
49
|
+
_searchable: {
|
|
50
|
+
// Use custom name if provided, otherwise will be set by plugin based on field name
|
|
51
|
+
embeddingFieldName: embeddingFieldName || '',
|
|
52
|
+
provider,
|
|
53
|
+
dimensions,
|
|
54
|
+
chunking,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -10,6 +10,21 @@ const MODEL_DIMENSIONS: Record<OpenAIEmbeddingModel, number> = {
|
|
|
10
10
|
'text-embedding-ada-002': 1536,
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Lazily load OpenAI to avoid requiring it at import time
|
|
15
|
+
*/
|
|
16
|
+
async function getOpenAI() {
|
|
17
|
+
try {
|
|
18
|
+
const module = await import('openai')
|
|
19
|
+
return module.default
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'OpenAI package not found. Install it with: npm install openai\n' +
|
|
23
|
+
'Make sure to run: pnpm install openai',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Type for OpenAI client (avoids direct dependency)
|
|
15
30
|
*/
|
|
@@ -34,35 +49,33 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
|
34
49
|
readonly model: string
|
|
35
50
|
readonly dimensions: number
|
|
36
51
|
|
|
37
|
-
private client: OpenAIClient
|
|
52
|
+
private client: OpenAIClient | null = null
|
|
38
53
|
private config: OpenAIEmbeddingConfig
|
|
54
|
+
private clientPromise: Promise<OpenAIClient> | null = null
|
|
39
55
|
|
|
40
56
|
constructor(config: OpenAIEmbeddingConfig) {
|
|
41
57
|
this.config = config
|
|
42
58
|
this.model = config.model || 'text-embedding-3-small'
|
|
43
59
|
this.dimensions = MODEL_DIMENSIONS[this.model as OpenAIEmbeddingModel] || 1536
|
|
60
|
+
}
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
this.client
|
|
62
|
+
private async ensureClient(): Promise<OpenAIClient> {
|
|
63
|
+
if (this.client) return this.client
|
|
64
|
+
if (this.clientPromise) return this.clientPromise
|
|
65
|
+
|
|
66
|
+
this.clientPromise = this.initializeClient()
|
|
67
|
+
this.client = await this.clientPromise
|
|
68
|
+
return this.client
|
|
47
69
|
}
|
|
48
70
|
|
|
49
|
-
private initializeClient(): OpenAIClient {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
baseURL: this.config.baseURL,
|
|
58
|
-
}) as OpenAIClient
|
|
59
|
-
} catch (error) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
'OpenAI package not found. Install it with: npm install openai\n' +
|
|
62
|
-
'Error: ' +
|
|
63
|
-
(error as Error).message,
|
|
64
|
-
)
|
|
65
|
-
}
|
|
71
|
+
private async initializeClient(): Promise<OpenAIClient> {
|
|
72
|
+
const OpenAI = await getOpenAI()
|
|
73
|
+
|
|
74
|
+
return new OpenAI({
|
|
75
|
+
apiKey: this.config.apiKey,
|
|
76
|
+
organization: this.config.organization,
|
|
77
|
+
baseURL: this.config.baseURL,
|
|
78
|
+
}) as OpenAIClient
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
/**
|
|
@@ -74,7 +87,8 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
try {
|
|
77
|
-
const
|
|
90
|
+
const client = await this.ensureClient()
|
|
91
|
+
const response = await client.embeddings.create({
|
|
78
92
|
model: this.model,
|
|
79
93
|
input: text,
|
|
80
94
|
encoding_format: 'float',
|
|
@@ -111,7 +125,8 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
|
111
125
|
|
|
112
126
|
try {
|
|
113
127
|
// OpenAI supports batch embedding
|
|
114
|
-
const
|
|
128
|
+
const client = await this.ensureClient()
|
|
129
|
+
const response = await client.embeddings.create({
|
|
115
130
|
model: this.model,
|
|
116
131
|
input: validTexts,
|
|
117
132
|
encoding_format: 'float',
|
|
@@ -267,7 +267,7 @@ describe('ProcessingQueue', () => {
|
|
|
267
267
|
// With concurrency 3, should be faster than sequential
|
|
268
268
|
// 5 items with 10ms each sequentially = 50ms
|
|
269
269
|
// With concurrency 3: ceil(5/3) * 10ms = 20ms
|
|
270
|
-
expect(duration).toBeLessThan(
|
|
270
|
+
expect(duration).toBeLessThan(50)
|
|
271
271
|
})
|
|
272
272
|
|
|
273
273
|
it('should track queue size', async () => {
|