@memberjunction/server 5.21.0 → 5.22.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/README.md +9 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +32 -2
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +24 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +113 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/PipelineProgressResolver.d.ts +33 -0
- package/dist/resolvers/PipelineProgressResolver.d.ts.map +1 -0
- package/dist/resolvers/PipelineProgressResolver.js +138 -0
- package/dist/resolvers/PipelineProgressResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.js +4 -4
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +85 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.js +587 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts +21 -0
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -0
- package/dist/resolvers/VectorizeEntityResolver.js +134 -0
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -0
- package/package.json +63 -62
- package/src/agents/skip-sdk.ts +31 -2
- package/src/generated/generated.ts +83 -0
- package/src/index.ts +3 -0
- package/src/resolvers/PipelineProgressResolver.ts +107 -0
- package/src/resolvers/RunAIAgentResolver.ts +4 -4
- package/src/resolvers/SearchKnowledgeResolver.ts +614 -0
- package/src/resolvers/VectorizeEntityResolver.ts +123 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
|
|
2
|
+
import { AppContext } from '../types.js';
|
|
3
|
+
import { LogError, LogStatus, Metadata, RunView, UserInfo, ComputeRRF, ScoredCandidate, EntityRecordNameInput, CompositeKey } from '@memberjunction/core';
|
|
4
|
+
import { MJVectorIndexEntity, MJVectorDatabaseEntity } from '@memberjunction/core-entities';
|
|
5
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
|
+
import { AIEngine } from '@memberjunction/aiengine';
|
|
7
|
+
import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
|
|
8
|
+
import { VectorDBBase, BaseResponse } from '@memberjunction/ai-vectordb';
|
|
9
|
+
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
10
|
+
|
|
11
|
+
/* ───── GraphQL types ───── */
|
|
12
|
+
|
|
13
|
+
@ObjectType()
|
|
14
|
+
export class SearchScoreBreakdown {
|
|
15
|
+
@Field(() => Float, { nullable: true })
|
|
16
|
+
Vector?: number;
|
|
17
|
+
|
|
18
|
+
@Field(() => Float, { nullable: true })
|
|
19
|
+
FullText?: number;
|
|
20
|
+
|
|
21
|
+
@Field(() => Float, { nullable: true })
|
|
22
|
+
Entity?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@ObjectType()
|
|
26
|
+
export class SearchKnowledgeResultItem {
|
|
27
|
+
@Field()
|
|
28
|
+
ID: string;
|
|
29
|
+
|
|
30
|
+
@Field()
|
|
31
|
+
EntityName: string;
|
|
32
|
+
|
|
33
|
+
@Field()
|
|
34
|
+
RecordID: string;
|
|
35
|
+
|
|
36
|
+
@Field()
|
|
37
|
+
SourceType: string;
|
|
38
|
+
|
|
39
|
+
@Field()
|
|
40
|
+
Title: string;
|
|
41
|
+
|
|
42
|
+
@Field()
|
|
43
|
+
Snippet: string;
|
|
44
|
+
|
|
45
|
+
@Field(() => Float)
|
|
46
|
+
Score: number;
|
|
47
|
+
|
|
48
|
+
@Field(() => SearchScoreBreakdown)
|
|
49
|
+
ScoreBreakdown: SearchScoreBreakdown;
|
|
50
|
+
|
|
51
|
+
@Field(() => [String])
|
|
52
|
+
Tags: string[];
|
|
53
|
+
|
|
54
|
+
@Field({ nullable: true })
|
|
55
|
+
EntityIcon?: string;
|
|
56
|
+
|
|
57
|
+
@Field({ nullable: true })
|
|
58
|
+
RecordName?: string;
|
|
59
|
+
|
|
60
|
+
@Field()
|
|
61
|
+
MatchedAt: Date;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@ObjectType()
|
|
65
|
+
export class SearchSourceCounts {
|
|
66
|
+
@Field()
|
|
67
|
+
Vector: number;
|
|
68
|
+
|
|
69
|
+
@Field()
|
|
70
|
+
FullText: number;
|
|
71
|
+
|
|
72
|
+
@Field()
|
|
73
|
+
Entity: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@ObjectType()
|
|
77
|
+
export class SearchKnowledgeResult {
|
|
78
|
+
@Field()
|
|
79
|
+
Success: boolean;
|
|
80
|
+
|
|
81
|
+
@Field(() => [SearchKnowledgeResultItem])
|
|
82
|
+
Results: SearchKnowledgeResultItem[];
|
|
83
|
+
|
|
84
|
+
@Field()
|
|
85
|
+
TotalCount: number;
|
|
86
|
+
|
|
87
|
+
@Field()
|
|
88
|
+
ElapsedMs: number;
|
|
89
|
+
|
|
90
|
+
@Field(() => SearchSourceCounts)
|
|
91
|
+
SourceCounts: SearchSourceCounts;
|
|
92
|
+
|
|
93
|
+
@Field({ nullable: true })
|
|
94
|
+
ErrorMessage?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@InputType()
|
|
98
|
+
export class SearchFiltersInput {
|
|
99
|
+
@Field(() => [String], { nullable: true })
|
|
100
|
+
EntityNames?: string[];
|
|
101
|
+
|
|
102
|
+
@Field(() => [String], { nullable: true })
|
|
103
|
+
SourceTypes?: string[];
|
|
104
|
+
|
|
105
|
+
@Field(() => [String], { nullable: true })
|
|
106
|
+
Tags?: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ───── Resolver ───── */
|
|
110
|
+
|
|
111
|
+
@Resolver()
|
|
112
|
+
export class SearchKnowledgeResolver extends ResolverBase {
|
|
113
|
+
|
|
114
|
+
@Mutation(() => SearchKnowledgeResult)
|
|
115
|
+
async SearchKnowledge(
|
|
116
|
+
@Arg('query') query: string,
|
|
117
|
+
@Arg('maxResults', () => Float, { nullable: true }) maxResults: number | undefined,
|
|
118
|
+
@Arg('filters', () => SearchFiltersInput, { nullable: true }) filters: SearchFiltersInput | undefined,
|
|
119
|
+
@Arg('minScore', () => Float, { nullable: true }) minScore: number | undefined,
|
|
120
|
+
@Ctx() { userPayload }: AppContext = {} as AppContext
|
|
121
|
+
): Promise<SearchKnowledgeResult> {
|
|
122
|
+
const startTime = Date.now();
|
|
123
|
+
try {
|
|
124
|
+
const currentUser = this.GetUserFromPayload(userPayload);
|
|
125
|
+
if (!currentUser) {
|
|
126
|
+
return this.errorResult('Unable to determine current user', startTime);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!query.trim()) {
|
|
130
|
+
return this.errorResult('Query cannot be empty', startTime);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const topK = maxResults ?? 20;
|
|
134
|
+
|
|
135
|
+
let t0 = Date.now();
|
|
136
|
+
await AIEngine.Instance.Config(false, currentUser);
|
|
137
|
+
LogStatus(`SearchKnowledge: AIEngine.Config: ${Date.now() - t0}ms`);
|
|
138
|
+
|
|
139
|
+
// Run vector search and full-text search in parallel
|
|
140
|
+
t0 = Date.now();
|
|
141
|
+
const [vectorResults, fullTextResults] = await Promise.all([
|
|
142
|
+
this.searchAllVectorIndexes(query, topK, filters, currentUser),
|
|
143
|
+
this.searchFullText(query, topK, filters, currentUser)
|
|
144
|
+
]);
|
|
145
|
+
LogStatus(`SearchKnowledge: Vector(${vectorResults.length}) + FTS(${fullTextResults.length}): ${Date.now() - t0}ms`);
|
|
146
|
+
|
|
147
|
+
// Fuse results with RRF if we have results from multiple sources
|
|
148
|
+
t0 = Date.now();
|
|
149
|
+
const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
|
|
150
|
+
const dedupedResults = this.deduplicateResults(fusedResults);
|
|
151
|
+
|
|
152
|
+
// Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
|
|
153
|
+
const scoreThreshold = minScore ?? 0;
|
|
154
|
+
const filteredResults = scoreThreshold > 0
|
|
155
|
+
? dedupedResults.filter(r => r.Score >= scoreThreshold)
|
|
156
|
+
: dedupedResults;
|
|
157
|
+
LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
|
|
158
|
+
|
|
159
|
+
// Enrich with entity icons and record names
|
|
160
|
+
t0 = Date.now();
|
|
161
|
+
await this.enrichResults(filteredResults, currentUser);
|
|
162
|
+
LogStatus(`SearchKnowledge: Enrich (icons + names): ${Date.now() - t0}ms`);
|
|
163
|
+
LogStatus(`SearchKnowledge: Total: ${Date.now() - startTime}ms`);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
Success: true,
|
|
167
|
+
Results: filteredResults,
|
|
168
|
+
TotalCount: filteredResults.length,
|
|
169
|
+
ElapsedMs: Date.now() - startTime,
|
|
170
|
+
SourceCounts: {
|
|
171
|
+
Vector: vectorResults.length,
|
|
172
|
+
FullText: fullTextResults.length,
|
|
173
|
+
Entity: 0
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
178
|
+
LogError(`SearchKnowledge mutation failed: ${msg}`);
|
|
179
|
+
return this.errorResult(msg, startTime);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Search ALL vector indexes. Groups indexes by embedding model, embeds the query
|
|
185
|
+
* in parallel per model, then queries all indexes per model in parallel as each
|
|
186
|
+
* embedding completes. Maximum concurrency at every level.
|
|
187
|
+
*/
|
|
188
|
+
private async searchAllVectorIndexes(
|
|
189
|
+
query: string,
|
|
190
|
+
topK: number,
|
|
191
|
+
filters: SearchFiltersInput | undefined,
|
|
192
|
+
contextUser: UserInfo
|
|
193
|
+
): Promise<SearchKnowledgeResultItem[]> {
|
|
194
|
+
const rv = new RunView();
|
|
195
|
+
|
|
196
|
+
const indexResult = await rv.RunView<MJVectorIndexEntity>({
|
|
197
|
+
EntityName: 'MJ: Vector Indexes',
|
|
198
|
+
ResultType: 'entity_object'
|
|
199
|
+
}, contextUser);
|
|
200
|
+
|
|
201
|
+
if (!indexResult.Success || indexResult.Results.length === 0) {
|
|
202
|
+
LogStatus('SearchKnowledge: No vector indexes configured');
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Group indexes by EmbeddingModelID
|
|
207
|
+
const indexesByModel = this.groupIndexesByModel(indexResult.Results);
|
|
208
|
+
const pineFilter = this.buildPineconeFilter(filters);
|
|
209
|
+
|
|
210
|
+
// For each model group: embed query + query all indexes — all in parallel
|
|
211
|
+
const modelGroupPromises = Array.from(indexesByModel.entries()).map(
|
|
212
|
+
([embeddingModelID, indexes]) =>
|
|
213
|
+
this.embedAndQueryGroup(query, embeddingModelID, indexes, topK, pineFilter, contextUser)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const groupResults = await Promise.all(modelGroupPromises);
|
|
217
|
+
return groupResults.flat();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Group vector indexes by their EmbeddingModelID */
|
|
221
|
+
private groupIndexesByModel(indexes: MJVectorIndexEntity[]): Map<string, MJVectorIndexEntity[]> {
|
|
222
|
+
const groups = new Map<string, MJVectorIndexEntity[]>();
|
|
223
|
+
for (const index of indexes) {
|
|
224
|
+
const modelId = index.EmbeddingModelID;
|
|
225
|
+
const existing = groups.get(modelId);
|
|
226
|
+
if (existing) {
|
|
227
|
+
existing.push(index);
|
|
228
|
+
} else {
|
|
229
|
+
groups.set(modelId, [index]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return groups;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Embed query with one model, then immediately query all indexes that use that model.
|
|
237
|
+
* The embedding and subsequent queries are chained so index queries fire as soon as
|
|
238
|
+
* the embedding completes — without waiting for other models' embeddings.
|
|
239
|
+
*/
|
|
240
|
+
private async embedAndQueryGroup(
|
|
241
|
+
query: string,
|
|
242
|
+
embeddingModelID: string,
|
|
243
|
+
indexes: MJVectorIndexEntity[],
|
|
244
|
+
topK: number,
|
|
245
|
+
filter: object | undefined,
|
|
246
|
+
contextUser: UserInfo
|
|
247
|
+
): Promise<SearchKnowledgeResultItem[]> {
|
|
248
|
+
try {
|
|
249
|
+
// Find the AI model for this embedding
|
|
250
|
+
const model = AIEngine.Instance.Models.find(m => UUIDsEqual(m.ID, embeddingModelID));
|
|
251
|
+
if (!model) {
|
|
252
|
+
LogError(`SearchKnowledge: Embedding model ${embeddingModelID} not found`);
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Create embedding instance
|
|
257
|
+
const apiKey = GetAIAPIKey(model.DriverClass);
|
|
258
|
+
const embedding = MJGlobal.Instance.ClassFactory.CreateInstance<BaseEmbeddings>(
|
|
259
|
+
BaseEmbeddings, model.DriverClass, apiKey
|
|
260
|
+
);
|
|
261
|
+
if (!embedding) {
|
|
262
|
+
LogError(`SearchKnowledge: Failed to create embedding for ${model.DriverClass}`);
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Embed the query with this model
|
|
267
|
+
const embedResult = await embedding.EmbedText({ text: query, model: '' });
|
|
268
|
+
if (!embedResult?.vector?.length) {
|
|
269
|
+
LogError(`SearchKnowledge: Failed to embed with ${model.Name}`);
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Query all indexes for this model in parallel
|
|
274
|
+
const indexPromises = indexes.map(vectorIndex =>
|
|
275
|
+
this.queryOneIndex(vectorIndex, embedResult.vector, topK, filter, contextUser)
|
|
276
|
+
.catch(error => {
|
|
277
|
+
LogError(`SearchKnowledge: Error querying index "${vectorIndex.Name}": ${error}`);
|
|
278
|
+
return [] as SearchKnowledgeResultItem[];
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const indexResults = await Promise.all(indexPromises);
|
|
283
|
+
return indexResults.flat();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
LogError(`SearchKnowledge: Error in embedding group ${embeddingModelID}: ${error}`);
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Query a single vector index by looking up its VectorDatabase provider and passing the index name.
|
|
292
|
+
*/
|
|
293
|
+
private async queryOneIndex(
|
|
294
|
+
vectorIndex: MJVectorIndexEntity,
|
|
295
|
+
queryVector: number[],
|
|
296
|
+
topK: number,
|
|
297
|
+
filter: object | undefined,
|
|
298
|
+
contextUser: UserInfo
|
|
299
|
+
): Promise<SearchKnowledgeResultItem[]> {
|
|
300
|
+
// Look up the vector database to get the ClassKey
|
|
301
|
+
const rv = new RunView();
|
|
302
|
+
const dbResult = await rv.RunView<MJVectorDatabaseEntity>({
|
|
303
|
+
EntityName: 'MJ: Vector Databases',
|
|
304
|
+
ExtraFilter: `ID='${vectorIndex.VectorDatabaseID}'`,
|
|
305
|
+
ResultType: 'entity_object'
|
|
306
|
+
}, contextUser);
|
|
307
|
+
|
|
308
|
+
if (!dbResult.Success || dbResult.Results.length === 0) {
|
|
309
|
+
LogError(`SearchKnowledge: VectorDatabase not found for index "${vectorIndex.Name}"`);
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const vectorDB = dbResult.Results[0];
|
|
314
|
+
const apiKey = GetAIAPIKey(vectorDB.ClassKey);
|
|
315
|
+
const vectorDBInstance = MJGlobal.Instance.ClassFactory.CreateInstance<VectorDBBase>(
|
|
316
|
+
VectorDBBase, vectorDB.ClassKey, apiKey
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (!vectorDBInstance) {
|
|
320
|
+
LogError(`SearchKnowledge: Failed to create VectorDB instance for "${vectorDB.ClassKey}"`);
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Query with the specific index name
|
|
325
|
+
const response: BaseResponse = await vectorDBInstance.QueryIndex({
|
|
326
|
+
id: vectorIndex.Name,
|
|
327
|
+
vector: queryVector,
|
|
328
|
+
topK,
|
|
329
|
+
includeMetadata: true,
|
|
330
|
+
filter,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (!response.success || !response.data?.matches) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return this.convertMatches(response.data.matches, vectorIndex.Name);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Full-text search using the MJCore Metadata.FullTextSearch() method.
|
|
342
|
+
* Delegates to the provider stack which uses database-native FTS
|
|
343
|
+
* (SQL Server FREETEXT, PostgreSQL tsvector) via RunView + UserSearchString.
|
|
344
|
+
*/
|
|
345
|
+
private async searchFullText(
|
|
346
|
+
query: string,
|
|
347
|
+
topK: number,
|
|
348
|
+
filters: SearchFiltersInput | undefined,
|
|
349
|
+
contextUser: UserInfo
|
|
350
|
+
): Promise<SearchKnowledgeResultItem[]> {
|
|
351
|
+
try {
|
|
352
|
+
const md = new Metadata();
|
|
353
|
+
const ftsResult = await md.FullTextSearch({
|
|
354
|
+
SearchText: query,
|
|
355
|
+
EntityNames: filters?.EntityNames,
|
|
356
|
+
MaxRowsPerEntity: Math.max(3, Math.ceil(topK / 10))
|
|
357
|
+
}, contextUser);
|
|
358
|
+
|
|
359
|
+
if (!ftsResult.Success) {
|
|
360
|
+
LogError(`SearchKnowledge: FTS error: ${ftsResult.ErrorMessage}`);
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return ftsResult.Results.map(r => ({
|
|
365
|
+
ID: `ft-${r.EntityName}-${r.RecordID}`,
|
|
366
|
+
EntityName: r.EntityName,
|
|
367
|
+
RecordID: r.RecordID,
|
|
368
|
+
SourceType: 'fulltext',
|
|
369
|
+
Title: r.Title,
|
|
370
|
+
Snippet: r.Snippet,
|
|
371
|
+
Score: r.Score,
|
|
372
|
+
ScoreBreakdown: { FullText: r.Score },
|
|
373
|
+
Tags: [],
|
|
374
|
+
MatchedAt: new Date()
|
|
375
|
+
}));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
LogError(`SearchKnowledge: Full-text search error: ${error}`);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
|
|
384
|
+
* Deduplicates by RecordID, preferring the higher-scored source.
|
|
385
|
+
*/
|
|
386
|
+
private fuseResults(
|
|
387
|
+
vectorResults: SearchKnowledgeResultItem[],
|
|
388
|
+
fullTextResults: SearchKnowledgeResultItem[],
|
|
389
|
+
topK: number
|
|
390
|
+
): SearchKnowledgeResultItem[] {
|
|
391
|
+
if (vectorResults.length === 0 && fullTextResults.length === 0) {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// If only one source has results, just return those
|
|
396
|
+
if (fullTextResults.length === 0) return vectorResults.slice(0, topK);
|
|
397
|
+
if (vectorResults.length === 0) return fullTextResults.slice(0, topK);
|
|
398
|
+
|
|
399
|
+
// Build scored candidate lists for RRF
|
|
400
|
+
const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
|
|
401
|
+
ID: r.RecordID,
|
|
402
|
+
Score: r.Score,
|
|
403
|
+
Rank: i + 1
|
|
404
|
+
}));
|
|
405
|
+
const ftCandidates: ScoredCandidate[] = fullTextResults.map((r, i) => ({
|
|
406
|
+
ID: r.RecordID,
|
|
407
|
+
Score: r.Score,
|
|
408
|
+
Rank: i + 1
|
|
409
|
+
}));
|
|
410
|
+
|
|
411
|
+
// Compute RRF scores
|
|
412
|
+
const fused = ComputeRRF([vectorCandidates, ftCandidates]);
|
|
413
|
+
|
|
414
|
+
// Map fused results back to full result items
|
|
415
|
+
const resultMap = new Map<string, SearchKnowledgeResultItem>();
|
|
416
|
+
for (const r of [...vectorResults, ...fullTextResults]) {
|
|
417
|
+
if (!resultMap.has(r.RecordID)) {
|
|
418
|
+
resultMap.set(r.RecordID, r);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return fused.slice(0, topK).map(candidate => {
|
|
423
|
+
const item = resultMap.get(candidate.ID);
|
|
424
|
+
if (item) {
|
|
425
|
+
item.Score = candidate.Score;
|
|
426
|
+
return item;
|
|
427
|
+
}
|
|
428
|
+
// Shouldn't happen, but fallback
|
|
429
|
+
return {
|
|
430
|
+
ID: candidate.ID,
|
|
431
|
+
EntityName: 'Unknown',
|
|
432
|
+
RecordID: candidate.ID,
|
|
433
|
+
SourceType: 'fused',
|
|
434
|
+
Title: 'Unknown',
|
|
435
|
+
Snippet: '',
|
|
436
|
+
Score: candidate.Score,
|
|
437
|
+
ScoreBreakdown: {},
|
|
438
|
+
Tags: [],
|
|
439
|
+
MatchedAt: new Date()
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Build Pinecone metadata filter from input */
|
|
445
|
+
private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
|
|
446
|
+
if (!filters) return undefined;
|
|
447
|
+
const conditions: object[] = [];
|
|
448
|
+
if (filters.EntityNames?.length) {
|
|
449
|
+
conditions.push({ Entity: { $in: filters.EntityNames } });
|
|
450
|
+
}
|
|
451
|
+
if (filters.SourceTypes?.length) {
|
|
452
|
+
conditions.push({ SourceType: { $in: filters.SourceTypes } });
|
|
453
|
+
}
|
|
454
|
+
if (filters.Tags?.length) {
|
|
455
|
+
conditions.push({ Tags: { $in: filters.Tags } });
|
|
456
|
+
}
|
|
457
|
+
if (conditions.length === 0) return undefined;
|
|
458
|
+
if (conditions.length === 1) return conditions[0];
|
|
459
|
+
return { $and: conditions };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Convert Pinecone matches to SearchKnowledgeResultItem[] using enriched metadata */
|
|
463
|
+
private convertMatches(
|
|
464
|
+
matches: Array<{ id: string; score?: number; metadata?: Record<string, unknown> }>,
|
|
465
|
+
indexName: string
|
|
466
|
+
): SearchKnowledgeResultItem[] {
|
|
467
|
+
return matches.map(match => {
|
|
468
|
+
const meta = match.metadata ?? {};
|
|
469
|
+
const entityName = (meta['Entity'] as string) ?? 'Unknown';
|
|
470
|
+
const recordID = (meta['RecordID'] as string) ?? match.id;
|
|
471
|
+
|
|
472
|
+
// Extract display fields from enriched metadata
|
|
473
|
+
const title = this.extractDisplayTitle(meta, entityName);
|
|
474
|
+
const snippet = this.extractDisplaySnippet(meta, indexName, match.score);
|
|
475
|
+
const entityIcon = (meta['EntityIcon'] as string) || undefined;
|
|
476
|
+
const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
ID: match.id,
|
|
480
|
+
EntityName: entityName,
|
|
481
|
+
RecordID: recordID,
|
|
482
|
+
SourceType: 'vector',
|
|
483
|
+
Title: title,
|
|
484
|
+
Snippet: snippet,
|
|
485
|
+
Score: match.score ?? 0,
|
|
486
|
+
ScoreBreakdown: { Vector: match.score ?? 0 },
|
|
487
|
+
Tags: [],
|
|
488
|
+
EntityIcon: entityIcon,
|
|
489
|
+
RecordName: title,
|
|
490
|
+
MatchedAt: updatedAt,
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Extract the best display title from vector metadata */
|
|
496
|
+
private extractDisplayTitle(meta: Record<string, unknown>, fallbackEntity: string): string {
|
|
497
|
+
// Check common name fields stored in metadata
|
|
498
|
+
const nameFields = ['Name', 'Title', 'Subject', 'Label', 'DisplayName'];
|
|
499
|
+
for (const field of nameFields) {
|
|
500
|
+
if (meta[field] && typeof meta[field] === 'string') {
|
|
501
|
+
return meta[field] as string;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return `${fallbackEntity} Record`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Extract the best display snippet from vector metadata */
|
|
508
|
+
private extractDisplaySnippet(meta: Record<string, unknown>, indexName: string, score?: number): string {
|
|
509
|
+
// Check common description fields stored in metadata
|
|
510
|
+
const descFields = ['Description', 'Summary', 'Body', 'Content', 'Text', 'Notes'];
|
|
511
|
+
for (const field of descFields) {
|
|
512
|
+
if (meta[field] && typeof meta[field] === 'string') {
|
|
513
|
+
const val = meta[field] as string;
|
|
514
|
+
return val.length > 200 ? val.substring(0, 200) + '...' : val;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Build a snippet from other metadata fields (exclude system fields)
|
|
519
|
+
const skipFields = new Set(['RecordID', 'Entity', 'TemplateID', 'EntityIcon', '__mj_UpdatedAt']);
|
|
520
|
+
const parts: string[] = [];
|
|
521
|
+
for (const [key, val] of Object.entries(meta)) {
|
|
522
|
+
if (skipFields.has(key) || val == null) continue;
|
|
523
|
+
const strVal = String(val);
|
|
524
|
+
if (strVal.length > 0 && strVal.length < 100) {
|
|
525
|
+
parts.push(`${key}: ${strVal}`);
|
|
526
|
+
}
|
|
527
|
+
if (parts.length >= 3) break;
|
|
528
|
+
}
|
|
529
|
+
if (parts.length > 0) return parts.join(' · ');
|
|
530
|
+
|
|
531
|
+
return `Matched from index "${indexName}" with score ${(score ?? 0).toFixed(4)}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Remove duplicate results by RecordID, keeping the highest-scored entry */
|
|
535
|
+
private deduplicateResults(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
|
|
536
|
+
const seen = new Map<string, SearchKnowledgeResultItem>();
|
|
537
|
+
for (const result of results) {
|
|
538
|
+
const key = `${result.EntityName}::${result.RecordID}`;
|
|
539
|
+
const existing = seen.get(key);
|
|
540
|
+
if (!existing || result.Score > existing.Score) {
|
|
541
|
+
seen.set(key, result);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Enrich results with entity icons and record names */
|
|
548
|
+
private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
|
|
549
|
+
const md = new Metadata();
|
|
550
|
+
|
|
551
|
+
// Add entity icons for results that don't already have them from metadata
|
|
552
|
+
for (const result of results) {
|
|
553
|
+
if (!result.EntityIcon) {
|
|
554
|
+
const entity = md.Entities.find(e => e.Name === result.EntityName);
|
|
555
|
+
if (entity?.Icon) {
|
|
556
|
+
result.EntityIcon = entity.Icon;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Only resolve record names for results that don't already have them
|
|
562
|
+
// (vector results from enriched metadata should already have names)
|
|
563
|
+
const needsNameResolution = results.filter(r =>
|
|
564
|
+
!r.RecordName || r.RecordName === `${r.EntityName} Record`
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
if (needsNameResolution.length === 0) return;
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const indexedResults: { index: number; input: EntityRecordNameInput }[] = [];
|
|
571
|
+
for (const r of needsNameResolution) {
|
|
572
|
+
const resultIndex = results.indexOf(r);
|
|
573
|
+
const entity = md.Entities.find(e => e.Name === r.EntityName);
|
|
574
|
+
if (!entity) continue;
|
|
575
|
+
|
|
576
|
+
const key = new CompositeKey();
|
|
577
|
+
key.LoadFromURLSegment(entity, r.RecordID);
|
|
578
|
+
|
|
579
|
+
const input = new EntityRecordNameInput();
|
|
580
|
+
input.EntityName = r.EntityName;
|
|
581
|
+
input.CompositeKey = key;
|
|
582
|
+
indexedResults.push({ index: resultIndex, input });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (indexedResults.length > 0) {
|
|
586
|
+
const names = await md.GetEntityRecordNames(
|
|
587
|
+
indexedResults.map(ir => ir.input),
|
|
588
|
+
contextUser
|
|
589
|
+
);
|
|
590
|
+
for (let i = 0; i < names.length; i++) {
|
|
591
|
+
if (names[i].RecordName) {
|
|
592
|
+
const resultIndex = indexedResults[i].index;
|
|
593
|
+
results[resultIndex].RecordName = names[i].RecordName;
|
|
594
|
+
results[resultIndex].Title = names[i].RecordName;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
LogError(`SearchKnowledge: Error resolving record names: ${error}`);
|
|
600
|
+
// Non-fatal — results still usable without names
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private errorResult(message: string, startTime: number): SearchKnowledgeResult {
|
|
605
|
+
return {
|
|
606
|
+
Success: false,
|
|
607
|
+
Results: [],
|
|
608
|
+
TotalCount: 0,
|
|
609
|
+
ElapsedMs: Date.now() - startTime,
|
|
610
|
+
SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },
|
|
611
|
+
ErrorMessage: message,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|