@equinor/fusion-framework-cli-plugin-ai-search 1.0.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/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/options/ai.js +152 -0
- package/dist/esm/options/ai.js.map +1 -0
- package/dist/esm/search.js +227 -0
- package/dist/esm/search.js.map +1 -0
- package/dist/esm/utils/setup-framework.js +53 -0
- package/dist/esm/utils/setup-framework.js.map +1 -0
- package/dist/esm/version.js +3 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/options/ai.d.ts +105 -0
- package/dist/types/search.d.ts +50 -0
- package/dist/types/utils/setup-framework.d.ts +10 -0
- package/dist/types/version.d.ts +1 -0
- package/package.json +58 -0
- package/src/index.ts +13 -0
- package/src/options/ai.ts +254 -0
- package/src/search.ts +284 -0
- package/src/utils/setup-framework.ts +75 -0
- package/src/version.ts +2 -0
- package/tsconfig.json +24 -0
package/src/search.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createCommand, createOption } from 'commander';
|
|
2
|
+
import type { Document } from '@langchain/core/documents';
|
|
3
|
+
import { withAiOptions, type AiOptions } from './options/ai.js';
|
|
4
|
+
|
|
5
|
+
import { setupFramework } from './utils/setup-framework.js';
|
|
6
|
+
import { inspect } from 'node:util';
|
|
7
|
+
import type { RetrieverOptions } from '@equinor/fusion-framework-module-ai/lib';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Command options for the search command
|
|
11
|
+
*/
|
|
12
|
+
type CommandOptions = AiOptions & {
|
|
13
|
+
/** Maximum number of search results to return */
|
|
14
|
+
limit: number;
|
|
15
|
+
/** Enable verbose output for debugging */
|
|
16
|
+
verbose: boolean;
|
|
17
|
+
/** OData filter expression for metadata-based filtering */
|
|
18
|
+
filter?: string;
|
|
19
|
+
/** Output results as JSON for programmatic use */
|
|
20
|
+
json: boolean;
|
|
21
|
+
/** Output raw metadata without normalization */
|
|
22
|
+
raw: boolean;
|
|
23
|
+
/** Search type: 'mmr' for Maximum Marginal Relevance or 'similarity' for similarity search */
|
|
24
|
+
searchType: 'mmr' | 'similarity';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes metadata attributes from Azure Search format to a flat object
|
|
29
|
+
* Azure Search returns metadata attributes as an array of {key, value} pairs,
|
|
30
|
+
* which this function converts to a simple key-value object for easier access
|
|
31
|
+
* @param metadata - Raw metadata object that may contain attributes array
|
|
32
|
+
* @returns Normalized metadata with attributes flattened into the root object
|
|
33
|
+
*/
|
|
34
|
+
const normalizeMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
35
|
+
const normalized = { ...metadata };
|
|
36
|
+
|
|
37
|
+
// Azure Search returns attributes as an array of {key, value} pairs
|
|
38
|
+
// Convert this to a flat object structure for easier access
|
|
39
|
+
if (Array.isArray(normalized.attributes)) {
|
|
40
|
+
const attributesObj: Record<string, unknown> = {};
|
|
41
|
+
for (const attr of normalized.attributes) {
|
|
42
|
+
// Validate attribute structure before processing
|
|
43
|
+
if (
|
|
44
|
+
typeof attr === 'object' &&
|
|
45
|
+
attr !== null &&
|
|
46
|
+
'key' in attr &&
|
|
47
|
+
'value' in attr &&
|
|
48
|
+
typeof attr.key === 'string'
|
|
49
|
+
) {
|
|
50
|
+
// Try to parse JSON values (Azure Search stores complex values as JSON strings)
|
|
51
|
+
// Fall back to raw value if parsing fails (for string values)
|
|
52
|
+
try {
|
|
53
|
+
attributesObj[attr.key] = JSON.parse(attr.value as string);
|
|
54
|
+
} catch {
|
|
55
|
+
attributesObj[attr.key] = attr.value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Merge flattened attributes into the root metadata object
|
|
60
|
+
Object.assign(normalized, attributesObj);
|
|
61
|
+
// Remove the original attributes array to avoid duplication
|
|
62
|
+
delete normalized.attributes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return normalized;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* CLI command: `ai search`
|
|
70
|
+
*
|
|
71
|
+
* Search the vector store to validate embeddings and retrieve relevant documents.
|
|
72
|
+
*
|
|
73
|
+
* Features:
|
|
74
|
+
* - Semantic search using vector embeddings
|
|
75
|
+
* - Configurable result limits
|
|
76
|
+
* - Filter support for metadata-based filtering
|
|
77
|
+
* - JSON output option for programmatic use
|
|
78
|
+
* - Detailed result display with scores and metadata
|
|
79
|
+
*
|
|
80
|
+
* Usage:
|
|
81
|
+
* $ ffc ai search <query> [options]
|
|
82
|
+
*
|
|
83
|
+
* Options:
|
|
84
|
+
* --limit <number> Maximum number of results to return (default: 10)
|
|
85
|
+
* --search-type <type> Search type: 'mmr' or 'similarity' (default: similarity)
|
|
86
|
+
* --filter <expression> OData filter expression for metadata filtering
|
|
87
|
+
* --json Output results as JSON
|
|
88
|
+
* --raw Output raw metadata without normalization
|
|
89
|
+
* --verbose Enable verbose output
|
|
90
|
+
* --openai-api-key <key> API key for Azure OpenAI
|
|
91
|
+
* --openai-api-version <version> API version (default: 2024-02-15-preview)
|
|
92
|
+
* --openai-instance <name> Azure OpenAI instance name
|
|
93
|
+
* --openai-embedding-deployment <name> Azure OpenAI embedding deployment name
|
|
94
|
+
* --azure-search-endpoint <url> Azure Search endpoint URL
|
|
95
|
+
* --azure-search-api-key <key> Azure Search API key
|
|
96
|
+
* --azure-search-index-name <name> Azure Search index name
|
|
97
|
+
*
|
|
98
|
+
* Environment Variables:
|
|
99
|
+
* AZURE_OPENAI_API_KEY API key for Azure OpenAI
|
|
100
|
+
* AZURE_OPENAI_API_VERSION API version
|
|
101
|
+
* AZURE_OPENAI_INSTANCE_NAME Instance name
|
|
102
|
+
* AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME Embedding deployment name
|
|
103
|
+
* AZURE_SEARCH_ENDPOINT Azure Search endpoint
|
|
104
|
+
* AZURE_SEARCH_API_KEY Azure Search API key
|
|
105
|
+
* AZURE_SEARCH_INDEX_NAME Azure Search index name
|
|
106
|
+
*
|
|
107
|
+
* Examples:
|
|
108
|
+
* $ ffc ai search "how to use the framework"
|
|
109
|
+
* $ ffc ai search "authentication" --limit 5
|
|
110
|
+
* $ ffc ai search "typescript" --filter "metadata/source eq 'src/index.ts'"
|
|
111
|
+
* $ ffc ai search "documentation" --search-type similarity
|
|
112
|
+
* $ ffc ai search "documentation" --json
|
|
113
|
+
* $ ffc ai search "documentation" --json --raw
|
|
114
|
+
* $ ffc ai search "API reference" --verbose
|
|
115
|
+
*/
|
|
116
|
+
export const command = withAiOptions(
|
|
117
|
+
createCommand('search')
|
|
118
|
+
.description('Search the vector store to validate embeddings and retrieve relevant documents')
|
|
119
|
+
.addOption(
|
|
120
|
+
createOption('--limit <number>', 'Maximum number of results to return')
|
|
121
|
+
.default(10)
|
|
122
|
+
.argParser(parseInt),
|
|
123
|
+
)
|
|
124
|
+
.addOption(
|
|
125
|
+
createOption('--search-type <type>', 'Search type: mmr or similarity')
|
|
126
|
+
.choices(['mmr', 'similarity'])
|
|
127
|
+
.default('similarity'),
|
|
128
|
+
)
|
|
129
|
+
.addOption(
|
|
130
|
+
createOption('--filter <expression>', 'OData filter expression for metadata filtering'),
|
|
131
|
+
)
|
|
132
|
+
.addOption(createOption('--json', 'Output results as JSON').default(false))
|
|
133
|
+
.addOption(createOption('--raw', 'Output raw metadata without normalization').default(false))
|
|
134
|
+
.addOption(createOption('--verbose', 'Enable verbose output').default(false))
|
|
135
|
+
.argument('<query>', 'Search query string')
|
|
136
|
+
.action(async (query: string, options: CommandOptions) => {
|
|
137
|
+
if (options.verbose) {
|
|
138
|
+
console.log('🔍 Initializing framework...');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const framework = await setupFramework(options);
|
|
142
|
+
|
|
143
|
+
if (!options.azureSearchIndexName) {
|
|
144
|
+
throw new Error('Azure Search index name is required');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.verbose) {
|
|
148
|
+
console.log('✅ Framework initialized successfully');
|
|
149
|
+
console.log(`🔎 Searching for: "${query}"`);
|
|
150
|
+
console.log(`📊 Limit: ${options.limit}`);
|
|
151
|
+
console.log(`🔍 Search type: ${options.searchType}`);
|
|
152
|
+
if (options.filter) {
|
|
153
|
+
console.log(`🔧 Filter: ${options.filter}`);
|
|
154
|
+
}
|
|
155
|
+
console.log('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const vectorStoreService = framework.ai.getService('search', options.azureSearchIndexName);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Configure retriever options for semantic search
|
|
162
|
+
// The retriever provides more control over search parameters than direct vector store queries
|
|
163
|
+
// RetrieverOptions is a discriminated union, so we construct it based on search type
|
|
164
|
+
const filter = options.filter
|
|
165
|
+
? {
|
|
166
|
+
filterExpression: options.filter,
|
|
167
|
+
}
|
|
168
|
+
: undefined;
|
|
169
|
+
|
|
170
|
+
// Construct retriever options based on search type
|
|
171
|
+
// MMR search requires additional parameters for diversity optimization
|
|
172
|
+
// Similarity search doesn't need these parameters
|
|
173
|
+
const retrieverOptions: RetrieverOptions =
|
|
174
|
+
options.searchType === 'mmr'
|
|
175
|
+
? {
|
|
176
|
+
k: options.limit,
|
|
177
|
+
searchType: 'mmr',
|
|
178
|
+
...(filter && { filter: filter as Record<string, unknown> }),
|
|
179
|
+
}
|
|
180
|
+
: {
|
|
181
|
+
k: options.limit,
|
|
182
|
+
searchType: 'similarity',
|
|
183
|
+
...(filter && { filter: filter as Record<string, unknown> }),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Create retriever and execute search query
|
|
187
|
+
const retriever = vectorStoreService.asRetriever(retrieverOptions);
|
|
188
|
+
const results = await retriever.invoke(query);
|
|
189
|
+
|
|
190
|
+
// Validate that results is an array
|
|
191
|
+
if (!results || !Array.isArray(results)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Invalid search results: expected array but got ${results === null ? 'null' : typeof results}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (options.json) {
|
|
198
|
+
// Output as JSON for programmatic use (e.g., piping to other tools)
|
|
199
|
+
for (const doc of results) {
|
|
200
|
+
if (options.raw) {
|
|
201
|
+
// Output raw document structure with full depth inspection
|
|
202
|
+
console.log(inspect(doc, { depth: null, colors: true }));
|
|
203
|
+
} else {
|
|
204
|
+
// Output normalized metadata for cleaner JSON structure
|
|
205
|
+
const metadata = normalizeMetadata(doc.metadata as Record<string, unknown>);
|
|
206
|
+
console.log({
|
|
207
|
+
content: doc.pageContent,
|
|
208
|
+
metadata,
|
|
209
|
+
score: (metadata as { score?: number })?.score,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// Format results for human-readable output
|
|
215
|
+
if (results.length === 0) {
|
|
216
|
+
console.log('❌ No results found');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(`✅ Found ${results.length} result${results.length !== 1 ? 's' : ''}:\n`);
|
|
221
|
+
|
|
222
|
+
results.forEach((doc: Document, index: number) => {
|
|
223
|
+
// Normalize metadata to flatten attributes array unless --raw flag is set
|
|
224
|
+
// Raw mode preserves Azure Search's original metadata structure
|
|
225
|
+
const processedMetadata = options.raw
|
|
226
|
+
? (doc.metadata as Record<string, unknown>)
|
|
227
|
+
: normalizeMetadata(doc.metadata as Record<string, unknown>);
|
|
228
|
+
const metadata = processedMetadata as {
|
|
229
|
+
source?: string;
|
|
230
|
+
score?: number;
|
|
231
|
+
[key: string]: unknown;
|
|
232
|
+
};
|
|
233
|
+
const score = metadata.score;
|
|
234
|
+
const source = metadata.source || 'Unknown source';
|
|
235
|
+
|
|
236
|
+
// Display result header with score if available
|
|
237
|
+
console.log(`${'─'.repeat(80)}`);
|
|
238
|
+
console.log(
|
|
239
|
+
`Result ${index + 1}${score !== undefined ? ` (Score: ${score.toFixed(4)})` : ''}`,
|
|
240
|
+
);
|
|
241
|
+
console.log(`Source: ${source}`);
|
|
242
|
+
|
|
243
|
+
// Display additional metadata fields if verbose mode is enabled
|
|
244
|
+
// Exclude source and score from additional metadata to avoid duplication
|
|
245
|
+
if (options.verbose) {
|
|
246
|
+
const { source: _, score: __, ...otherMetadata } = metadata;
|
|
247
|
+
if (Object.keys(otherMetadata).length > 0) {
|
|
248
|
+
console.log(`Metadata:`, JSON.stringify(otherMetadata, null, 2));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
console.log('');
|
|
252
|
+
|
|
253
|
+
// Truncate content if too long to keep output readable
|
|
254
|
+
// Full content is still available in JSON mode
|
|
255
|
+
const content = doc.pageContent;
|
|
256
|
+
const maxLength = 500;
|
|
257
|
+
if (content.length > maxLength) {
|
|
258
|
+
console.log(`${content.substring(0, maxLength)}...`);
|
|
259
|
+
console.log(`\n[Content truncated - ${content.length} characters total]`);
|
|
260
|
+
} else {
|
|
261
|
+
console.log(content);
|
|
262
|
+
}
|
|
263
|
+
console.log('');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log(`${'─'.repeat(80)}`);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(
|
|
270
|
+
`❌ Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
271
|
+
);
|
|
272
|
+
if (options.verbose && error instanceof Error && error.stack) {
|
|
273
|
+
console.error(error.stack);
|
|
274
|
+
}
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}),
|
|
278
|
+
{
|
|
279
|
+
includeEmbedding: true,
|
|
280
|
+
includeSearch: true,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
export default command;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { enableAI, type IAIConfigurator, type AIModule } from '@equinor/fusion-framework-module-ai';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AzureOpenAiEmbed,
|
|
5
|
+
AzureOpenAIModel,
|
|
6
|
+
AzureVectorStore,
|
|
7
|
+
} from '@equinor/fusion-framework-module-ai/azure';
|
|
8
|
+
|
|
9
|
+
import type { AiOptions } from '../options/ai.js';
|
|
10
|
+
import { ModulesConfigurator, type ModulesInstance } from '@equinor/fusion-framework-module';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initializes and configures the Fusion Framework with AI module capabilities
|
|
14
|
+
* @param options - AI configuration options including API keys, deployments, and vector store settings
|
|
15
|
+
* @returns Promise resolving to an initialized framework instance with AI module
|
|
16
|
+
* @throws {Error} If embedding deployment is required but not provided when configuring vector store
|
|
17
|
+
*/
|
|
18
|
+
export const setupFramework = async (options: AiOptions): Promise<ModulesInstance<[AIModule]>> => {
|
|
19
|
+
// Create a new module configurator for the framework
|
|
20
|
+
const configurator = new ModulesConfigurator<[AIModule]>();
|
|
21
|
+
|
|
22
|
+
// Configure AI module with provided options
|
|
23
|
+
enableAI(configurator, (aiConfig: IAIConfigurator) => {
|
|
24
|
+
// Configure chat model if deployment name is provided
|
|
25
|
+
if (options.openaiChatDeployment) {
|
|
26
|
+
aiConfig.setModel(
|
|
27
|
+
options.openaiChatDeployment,
|
|
28
|
+
new AzureOpenAIModel({
|
|
29
|
+
azureOpenAIApiKey: options.openaiApiKey,
|
|
30
|
+
azureOpenAIApiDeploymentName: options.openaiChatDeployment,
|
|
31
|
+
azureOpenAIApiInstanceName: options.openaiInstance,
|
|
32
|
+
azureOpenAIApiVersion: options.openaiApiVersion,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Configure embedding model if deployment name is provided
|
|
38
|
+
if (options.openaiEmbeddingDeployment) {
|
|
39
|
+
aiConfig.setEmbedding(
|
|
40
|
+
options.openaiEmbeddingDeployment,
|
|
41
|
+
new AzureOpenAiEmbed({
|
|
42
|
+
azureOpenAIApiKey: options.openaiApiKey,
|
|
43
|
+
azureOpenAIApiDeploymentName: options.openaiEmbeddingDeployment,
|
|
44
|
+
azureOpenAIApiInstanceName: options.openaiInstance,
|
|
45
|
+
azureOpenAIApiVersion: options.openaiApiVersion,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Configure vector store if Azure Search options are provided
|
|
51
|
+
// Vector store requires an embedding service to generate embeddings for documents
|
|
52
|
+
if (options.azureSearchEndpoint && options.azureSearchApiKey && options.azureSearchIndexName) {
|
|
53
|
+
if (!options.openaiEmbeddingDeployment) {
|
|
54
|
+
throw new Error('Embedding deployment is required to configure the vector store');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Retrieve the embedding service to pass to the vector store
|
|
58
|
+
// The vector store uses embeddings to index and search documents
|
|
59
|
+
const embeddingService = aiConfig.getService('embeddings', options.openaiEmbeddingDeployment);
|
|
60
|
+
|
|
61
|
+
aiConfig.setVectorStore(
|
|
62
|
+
options.azureSearchIndexName,
|
|
63
|
+
new AzureVectorStore(embeddingService, {
|
|
64
|
+
endpoint: options.azureSearchEndpoint,
|
|
65
|
+
key: options.azureSearchApiKey,
|
|
66
|
+
indexName: options.azureSearchIndexName,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Initialize the framework with all configured modules
|
|
73
|
+
const framework = await configurator.initialize();
|
|
74
|
+
return framework;
|
|
75
|
+
};
|
package/src/version.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist/esm",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"declarationDir": "./dist/types",
|
|
9
|
+
"baseUrl": "."
|
|
10
|
+
},
|
|
11
|
+
"references": [
|
|
12
|
+
{
|
|
13
|
+
"path": "../ai-base"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "../../modules/ai"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "../../modules/module"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"include": ["src/**/*"],
|
|
23
|
+
"exclude": ["node_modules"]
|
|
24
|
+
}
|