memento-mcp-server 1.15.0-c → 1.16.0-a

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.
Files changed (56) hide show
  1. package/dist/domains/relation/tools/visualize-relations-tool.d.ts +2 -2
  2. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts +60 -0
  3. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts.map +1 -0
  4. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js +276 -0
  5. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js.map +1 -0
  6. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.sql +128 -0
  7. package/dist/infrastructure/scheduler/batch-scheduler.d.ts +17 -0
  8. package/dist/infrastructure/scheduler/batch-scheduler.d.ts.map +1 -1
  9. package/dist/infrastructure/scheduler/batch-scheduler.js +124 -0
  10. package/dist/infrastructure/scheduler/batch-scheduler.js.map +1 -1
  11. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts +108 -0
  12. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts.map +1 -0
  13. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js +184 -0
  14. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js.map +1 -0
  15. package/dist/server/http-server.d.ts.map +1 -1
  16. package/dist/server/http-server.js +3 -0
  17. package/dist/server/http-server.js.map +1 -1
  18. package/dist/server/routes/quality.routes.d.ts +14 -0
  19. package/dist/server/routes/quality.routes.d.ts.map +1 -0
  20. package/dist/server/routes/quality.routes.js +460 -0
  21. package/dist/server/routes/quality.routes.js.map +1 -0
  22. package/dist/services/quality-assurance/quality-assurance-service.d.ts +207 -0
  23. package/dist/services/quality-assurance/quality-assurance-service.d.ts.map +1 -0
  24. package/dist/services/quality-assurance/quality-assurance-service.js +247 -0
  25. package/dist/services/quality-assurance/quality-assurance-service.js.map +1 -0
  26. package/dist/services/quality-assurance/quality-evaluator.d.ts +163 -0
  27. package/dist/services/quality-assurance/quality-evaluator.d.ts.map +1 -0
  28. package/dist/services/quality-assurance/quality-evaluator.js +256 -0
  29. package/dist/services/quality-assurance/quality-evaluator.js.map +1 -0
  30. package/dist/services/quality-assurance/quality-metrics-collector.d.ts +219 -0
  31. package/dist/services/quality-assurance/quality-metrics-collector.d.ts.map +1 -0
  32. package/dist/services/quality-assurance/quality-metrics-collector.js +725 -0
  33. package/dist/services/quality-assurance/quality-metrics-collector.js.map +1 -0
  34. package/dist/services/quality-assurance/quality-recorder.d.ts +108 -0
  35. package/dist/services/quality-assurance/quality-recorder.d.ts.map +1 -0
  36. package/dist/services/quality-assurance/quality-recorder.js +281 -0
  37. package/dist/services/quality-assurance/quality-recorder.js.map +1 -0
  38. package/dist/services/quality-assurance/quality-reporter.d.ts +189 -0
  39. package/dist/services/quality-assurance/quality-reporter.d.ts.map +1 -0
  40. package/dist/services/quality-assurance/quality-reporter.js +558 -0
  41. package/dist/services/quality-assurance/quality-reporter.js.map +1 -0
  42. package/dist/services/quality-assurance/quality-threshold-manager.d.ts +102 -0
  43. package/dist/services/quality-assurance/quality-threshold-manager.d.ts.map +1 -0
  44. package/dist/services/quality-assurance/quality-threshold-manager.js +252 -0
  45. package/dist/services/quality-assurance/quality-threshold-manager.js.map +1 -0
  46. package/dist/test/helpers/search-quality-metrics.d.ts +96 -0
  47. package/dist/test/helpers/search-quality-metrics.d.ts.map +1 -0
  48. package/dist/test/helpers/search-quality-metrics.js +185 -0
  49. package/dist/test/helpers/search-quality-metrics.js.map +1 -0
  50. package/dist/test/helpers/vector-search-quality-metrics.d.ts +1287 -0
  51. package/dist/test/helpers/vector-search-quality-metrics.d.ts.map +1 -0
  52. package/dist/test/helpers/vector-search-quality-metrics.js +2214 -0
  53. package/dist/test/helpers/vector-search-quality-metrics.js.map +1 -0
  54. package/package.json +4 -1
  55. package/scripts/quality-report.ts +166 -0
  56. package/scripts/quality-thresholds.ts +279 -0
@@ -0,0 +1,2214 @@
1
+ /**
2
+ * 벡터 검색 품질 검증 헬퍼
3
+ * 벡터 검색 결과 순서 보존 검증 및 품질 지표 비교
4
+ * Consolidation 점수 반영 전/후 비교를 위한 지표 계산
5
+ */
6
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { calculatePrecisionAtK, calculateRecallAtK, calculateNDCGAtK } from './search-quality-metrics.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ /**
13
+ * Kendall's Tau-b 순서 일치도 계산
14
+ * 두 순서 간의 순위 상관관계를 측정 (-1 ~ 1)
15
+ *
16
+ * Tau-b는 동점(tie)을 처리하는 버전으로, 검색 결과에서 동일 점수를 가진 항목들을 올바르게 처리합니다.
17
+ *
18
+ * @param order1 첫 번째 순서 (ID 배열, 점수 순으로 정렬됨)
19
+ * @param order2 두 번째 순서 (ID 배열, 점수 순으로 정렬됨)
20
+ * @returns Kendall's Tau-b 값 (-1 ~ 1)
21
+ * - 1: 완전히 일치하는 순서
22
+ * - 0: 무관한 순서
23
+ * - -1: 완전히 반대인 순서
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const order1 = ['id1', 'id2', 'id3', 'id4'];
28
+ * const order2 = ['id1', 'id3', 'id2', 'id4'];
29
+ * const tau = calculateKendallTau(order1, order2);
30
+ * // tau는 0.67 정도 (부분적으로 일치)
31
+ * ```
32
+ */
33
+ export function calculateKendallTau(order1, order2) {
34
+ // 빈 배열 처리
35
+ if (order1.length === 0 || order2.length === 0) {
36
+ return 0;
37
+ }
38
+ // 두 순서에서 공통 ID만 추출
39
+ const set1 = new Set(order1);
40
+ const set2 = new Set(order2);
41
+ const commonIds = order1.filter(id => set2.has(id));
42
+ if (commonIds.length < 2) {
43
+ // 공통 ID가 2개 미만이면 순서 비교 불가
44
+ return 0;
45
+ }
46
+ // 각 ID의 위치를 매핑
47
+ const position1 = new Map();
48
+ const position2 = new Map();
49
+ order1.forEach((id, index) => {
50
+ if (set2.has(id)) {
51
+ position1.set(id, index);
52
+ }
53
+ });
54
+ order2.forEach((id, index) => {
55
+ if (set1.has(id)) {
56
+ position2.set(id, index);
57
+ }
58
+ });
59
+ // 공통 ID만 사용하여 순서 비교
60
+ const n = commonIds.length;
61
+ let concordant = 0; // 일치하는 쌍
62
+ let discordant = 0; // 불일치하는 쌍
63
+ let tiedX = 0; // order1에서 동점인 쌍
64
+ let tiedY = 0; // order2에서 동점인 쌍
65
+ let tiedXY = 0; // 양쪽 모두 동점인 쌍
66
+ // 모든 쌍을 비교
67
+ for (let i = 0; i < n; i++) {
68
+ for (let j = i + 1; j < n; j++) {
69
+ const idI = commonIds[i];
70
+ const idJ = commonIds[j];
71
+ if (idI === undefined || idJ === undefined) {
72
+ continue;
73
+ }
74
+ const pos1I = position1.get(idI);
75
+ const pos1J = position1.get(idJ);
76
+ const pos2I = position2.get(idI);
77
+ const pos2J = position2.get(idJ);
78
+ if (pos1I === undefined || pos1J === undefined || pos2I === undefined || pos2J === undefined) {
79
+ continue;
80
+ }
81
+ // order1에서의 관계
82
+ const sign1 = pos1I < pos1J ? 1 : pos1I > pos1J ? -1 : 0;
83
+ // order2에서의 관계
84
+ const sign2 = pos2I < pos2J ? 1 : pos2I > pos2J ? -1 : 0;
85
+ if (sign1 === 0 && sign2 === 0) {
86
+ // 양쪽 모두 동점
87
+ tiedXY++;
88
+ }
89
+ else if (sign1 === 0) {
90
+ // order1에서만 동점
91
+ tiedX++;
92
+ }
93
+ else if (sign2 === 0) {
94
+ // order2에서만 동점
95
+ tiedY++;
96
+ }
97
+ else if (sign1 === sign2) {
98
+ // 일치하는 쌍 (둘 다 앞서거나 둘 다 뒤)
99
+ concordant++;
100
+ }
101
+ else {
102
+ // 불일치하는 쌍 (하나는 앞서고 하나는 뒤)
103
+ discordant++;
104
+ }
105
+ }
106
+ }
107
+ // Tau-b 계산
108
+ // Tau-b = (concordant - discordant) / sqrt((concordant + discordant + tied_x) * (concordant + discordant + tied_y))
109
+ const numerator = concordant - discordant;
110
+ const denominatorX = concordant + discordant + tiedX;
111
+ const denominatorY = concordant + discordant + tiedY;
112
+ const denominator = Math.sqrt(denominatorX * denominatorY);
113
+ if (denominator === 0) {
114
+ // 분모가 0이면 모든 쌍이 동점이거나 비교 불가
115
+ return 0;
116
+ }
117
+ return numerator / denominator;
118
+ }
119
+ /**
120
+ * Spearman's Rho 순서 일치도 계산
121
+ * 두 순서 간의 순위 상관관계를 측정 (-1 ~ 1)
122
+ *
123
+ * Spearman's Rho는 순위 차이를 기반으로 계산되며, Kendall's Tau와 함께 사용하여
124
+ * 순서 보존 정도를 다각도로 검증할 수 있습니다.
125
+ *
126
+ * @param order1 첫 번째 순서 (ID 배열, 점수 순으로 정렬됨)
127
+ * @param order2 두 번째 순서 (ID 배열, 점수 순으로 정렬됨)
128
+ * @returns Spearman's Rho 값 (-1 ~ 1)
129
+ * - 1: 완전히 일치하는 순서
130
+ * - 0: 무관한 순서
131
+ * - -1: 완전히 반대인 순서
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const order1 = ['id1', 'id2', 'id3', 'id4'];
136
+ * const order2 = ['id1', 'id3', 'id2', 'id4'];
137
+ * const rho = calculateSpearmanRho(order1, order2);
138
+ * // rho는 0.8 정도 (대체로 일치)
139
+ * ```
140
+ */
141
+ export function calculateSpearmanRho(order1, order2) {
142
+ // 빈 배열 처리
143
+ if (order1.length === 0 || order2.length === 0) {
144
+ return 0;
145
+ }
146
+ // 두 순서에서 공통 ID만 추출
147
+ const set1 = new Set(order1);
148
+ const set2 = new Set(order2);
149
+ const commonIds = order1.filter(id => set2.has(id));
150
+ if (commonIds.length < 2) {
151
+ // 공통 ID가 2개 미만이면 순서 비교 불가
152
+ return 0;
153
+ }
154
+ // 각 ID의 순위를 계산 (동점 처리 포함)
155
+ const rank1 = calculateRanks(order1, commonIds);
156
+ const rank2 = calculateRanks(order2, commonIds);
157
+ // 순위 차이의 제곱합 계산
158
+ let sumSquaredDiff = 0;
159
+ for (const id of commonIds) {
160
+ const diff = rank1.get(id) - rank2.get(id);
161
+ sumSquaredDiff += diff * diff;
162
+ }
163
+ // Spearman's Rho 계산
164
+ // Rho = 1 - (6 * sum(d^2)) / (n * (n^2 - 1))
165
+ const n = commonIds.length;
166
+ const denominator = n * (n * n - 1);
167
+ if (denominator === 0) {
168
+ return 0;
169
+ }
170
+ return 1 - (6 * sumSquaredDiff) / denominator;
171
+ }
172
+ /**
173
+ * 순위 계산 헬퍼 함수
174
+ * 각 ID의 위치를 순위로 사용 (1-based)
175
+ *
176
+ * 동점 처리는 실제 점수를 비교해야 하지만, 여기서는 위치 기반으로 순위를 계산합니다.
177
+ * 실제 점수 정보가 필요한 경우, SearchResult의 score 필드를 사용하여
178
+ * 동점인 항목들에 평균 순위를 할당할 수 있습니다.
179
+ *
180
+ * @param order 순서 (ID 배열, 점수 순으로 정렬됨)
181
+ * @param targetIds 순위를 계산할 ID 목록 (공통 ID)
182
+ * @returns ID별 순위 맵 (1-based)
183
+ */
184
+ function calculateRanks(order, targetIds) {
185
+ const ranks = new Map();
186
+ const targetSet = new Set(targetIds);
187
+ // 각 ID의 위치를 순위로 사용 (1-based)
188
+ order.forEach((id, index) => {
189
+ if (targetSet.has(id)) {
190
+ ranks.set(id, index + 1); // 1-based 순위
191
+ }
192
+ });
193
+ // targetIds에 있지만 order에 없는 ID는 마지막 순위로 처리
194
+ for (const id of targetIds) {
195
+ if (!ranks.has(id)) {
196
+ ranks.set(id, order.length + 1);
197
+ }
198
+ }
199
+ return ranks;
200
+ }
201
+ /**
202
+ * 상위 K개 결과 유지율 계산
203
+ * 벡터-only 상위 K개가 Consolidation 반영 후에도 상위에 유지되는 비율
204
+ *
205
+ * Acceptance Criteria:
206
+ * - Top10 유지율 >= 80%
207
+ * - Top5 유지율 >= 90%
208
+ *
209
+ * @param pair 벡터-only와 Consolidation 반영 후 검색 결과 쌍
210
+ * @param kValues 계산할 K 값 배열 (예: [5, 10])
211
+ * @returns K 값별 유지율 (0 ~ 1)
212
+ *
213
+ * @example
214
+ * ```typescript
215
+ * const pair = {
216
+ * vectorOnly: [{ id: 'id1' }, { id: 'id2' }, { id: 'id3' }, ...],
217
+ * withConsolidation: [{ id: 'id1' }, { id: 'id3' }, { id: 'id2' }, ...]
218
+ * };
219
+ * const retention = calculateTopKRetention(pair, [5, 10]);
220
+ * // retention = { 5: 0.8, 10: 0.7 }
221
+ * ```
222
+ */
223
+ export function calculateTopKRetention(pair, kValues = [5, 10]) {
224
+ const retention = {};
225
+ for (const k of kValues) {
226
+ // 벡터-only 상위 K개 ID 추출
227
+ const vectorTopK = pair.vectorOnly.slice(0, k).map(r => r.id);
228
+ const vectorTopKSet = new Set(vectorTopK);
229
+ // Consolidation 반영 후 상위 K개 ID 추출
230
+ const consolidationTopK = pair.withConsolidation.slice(0, k).map(r => r.id);
231
+ const consolidationTopKSet = new Set(consolidationTopK);
232
+ // 교집합 계산: 벡터-only 상위 K개가 Consolidation 상위 K개에도 포함된 개수
233
+ let intersectionCount = 0;
234
+ for (const id of vectorTopK) {
235
+ if (consolidationTopKSet.has(id)) {
236
+ intersectionCount++;
237
+ }
238
+ }
239
+ // 유지율 계산
240
+ // 유지율 = (벡터-only 상위 K개 중 Consolidation 상위 K개에도 포함된 개수) / K
241
+ if (k === 0) {
242
+ retention[k] = 0;
243
+ }
244
+ else {
245
+ retention[k] = intersectionCount / k;
246
+ }
247
+ }
248
+ return retention;
249
+ }
250
+ /**
251
+ * 벡터 유사도만 사용한 검색 결과 생성
252
+ * Consolidation 점수를 제외하고 벡터 유사도만으로 정렬한 결과를 생성합니다.
253
+ *
254
+ * 이 함수는 실제 검색 결과를 받아서 벡터 유사도만으로 재정렬합니다.
255
+ * 벡터 유사도가 없는 경우 textScore를 사용하거나, 해당 결과를 제외할 수 있습니다.
256
+ *
257
+ * @param searchResults 실제 검색 결과 (HybridSearchResult 배열)
258
+ * @param limit 반환할 결과 수 (기본값: 전체)
259
+ * @returns 벡터 유사도만으로 정렬된 SearchResult 배열
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * const results = await hybridSearchEngine.search(query, options);
264
+ * const vectorOnlyResults = generateVectorOnlySearchResults(results);
265
+ * ```
266
+ */
267
+ export function generateVectorOnlySearchResults(searchResults, limit) {
268
+ // 벡터 유사도가 있는 결과만 필터링하고, vectorScore로 정렬
269
+ const vectorOnlyResults = searchResults
270
+ .filter(result => result.vectorScore !== undefined && result.vectorScore !== null)
271
+ .map(result => ({
272
+ id: result.id,
273
+ score: result.vectorScore, // 벡터 유사도를 score로 사용
274
+ finalScore: result.vectorScore, // 벡터 유사도만 사용하므로 finalScore도 동일
275
+ relevance: result.vectorScore // 관련성 점수로도 사용
276
+ }))
277
+ .sort((a, b) => (b.score || 0) - (a.score || 0)); // 내림차순 정렬
278
+ // limit이 지정된 경우 상위 N개만 반환
279
+ if (limit !== undefined && limit > 0) {
280
+ return vectorOnlyResults.slice(0, limit);
281
+ }
282
+ return vectorOnlyResults;
283
+ }
284
+ /**
285
+ * Consolidation 점수 반영 후 검색 결과 생성
286
+ * 벡터 유사도와 Consolidation 점수가 모두 반영된 최종 점수로 정렬한 결과를 생성합니다.
287
+ *
288
+ * 이 함수는 실제 검색 결과를 받아서 finalScore(벡터 유사도 + Consolidation 점수)로 정렬합니다.
289
+ * finalScore는 이미 검색 엔진에서 계산된 최종 점수입니다.
290
+ *
291
+ * @param searchResults 실제 검색 결과 (HybridSearchResult 배열)
292
+ * @param limit 반환할 결과 수 (기본값: 전체)
293
+ * @returns Consolidation 점수 반영 후 정렬된 SearchResult 배열
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * const results = await hybridSearchEngine.search(query, options);
298
+ * const consolidationResults = generateConsolidationSearchResults(results);
299
+ * ```
300
+ */
301
+ export function generateConsolidationSearchResults(searchResults, limit) {
302
+ // finalScore를 사용하여 정렬 (벡터 유사도 + Consolidation 점수 반영)
303
+ const consolidationResults = searchResults
304
+ .filter(result => result.finalScore !== undefined && result.finalScore !== null)
305
+ .map(result => ({
306
+ id: result.id,
307
+ score: result.finalScore, // 최종 점수를 score로 사용
308
+ finalScore: result.finalScore, // finalScore 그대로 사용
309
+ relevance: result.vectorScore || result.textScore || 0 // 관련성 점수는 벡터 유사도 또는 텍스트 점수 사용
310
+ }))
311
+ .sort((a, b) => (b.finalScore || 0) - (a.finalScore || 0)); // 내림차순 정렬
312
+ // limit이 지정된 경우 상위 N개만 반환
313
+ if (limit !== undefined && limit > 0) {
314
+ return consolidationResults.slice(0, limit);
315
+ }
316
+ return consolidationResults;
317
+ }
318
+ /**
319
+ * 순서 보존 검증 결과 리포트 생성
320
+ * 벡터-only 결과와 Consolidation 반영 후 결과 간의 순서 보존 정도를 검증하고 리포트를 생성합니다.
321
+ *
322
+ * Acceptance Criteria:
323
+ * - Kendall's Tau >= 0.7
324
+ * - Top10 유지율 >= 80%
325
+ * - Top5 유지율 >= 90%
326
+ *
327
+ * @param pair 벡터-only와 Consolidation 반영 후 검색 결과 쌍
328
+ * @param options 리포트 생성 옵션
329
+ * @param options.includeSpearmanRho Spearman's Rho 계산 포함 여부 (기본값: false)
330
+ * @param options.kValues TopK 유지율 계산할 K 값 배열 (기본값: [5, 10])
331
+ * @param options.kendallTauThreshold Kendall's Tau 임계값 (기본값: 0.7)
332
+ * @param options.top10RetentionThreshold Top10 유지율 임계값 (기본값: 0.8)
333
+ * @param options.top5RetentionThreshold Top5 유지율 임계값 (기본값: 0.9)
334
+ * @returns 순서 보존 검증 결과 리포트
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * const pair = {
339
+ * vectorOnly: vectorOnlyResults,
340
+ * withConsolidation: consolidationResults
341
+ * };
342
+ * const report = generateOrderPreservationReport(pair);
343
+ * console.log(`검증 통과: ${report.passed}`);
344
+ * ```
345
+ */
346
+ export function generateOrderPreservationReport(pair, options = {}) {
347
+ const { includeSpearmanRho = false, kValues = [5, 10], kendallTauThreshold = 0.7, top10RetentionThreshold = 0.8, top5RetentionThreshold = 0.9 } = options;
348
+ // ID 배열 추출
349
+ const vectorOnlyIds = pair.vectorOnly.map(r => r.id);
350
+ const consolidationIds = pair.withConsolidation.map(r => r.id);
351
+ // Kendall's Tau 계산
352
+ const kendallTau = calculateKendallTau(vectorOnlyIds, consolidationIds);
353
+ // Spearman's Rho 계산 (선택적)
354
+ const spearmanRho = includeSpearmanRho
355
+ ? calculateSpearmanRho(vectorOnlyIds, consolidationIds)
356
+ : undefined;
357
+ // TopK 유지율 계산
358
+ const topKRetention = calculateTopKRetention(pair, kValues);
359
+ // 검증 수행
360
+ const kendallTauValid = kendallTau >= kendallTauThreshold;
361
+ const top10Retention = topKRetention[10] || 0;
362
+ const top5Retention = topKRetention[5] || 0;
363
+ const top10RetentionValid = top10Retention >= top10RetentionThreshold;
364
+ const top5RetentionValid = top5Retention >= top5RetentionThreshold;
365
+ // 전체 검증 통과 여부
366
+ const passed = kendallTauValid && top10RetentionValid && top5RetentionValid;
367
+ // 실패 사유 수집
368
+ const failureReasons = [];
369
+ if (!kendallTauValid) {
370
+ failureReasons.push(`Kendall's Tau (${kendallTau.toFixed(3)}) < 임계값 (${kendallTauThreshold})`);
371
+ }
372
+ if (!top10RetentionValid) {
373
+ failureReasons.push(`Top10 유지율 (${(top10Retention * 100).toFixed(1)}%) < 임계값 (${(top10RetentionThreshold * 100).toFixed(1)}%)`);
374
+ }
375
+ if (!top5RetentionValid) {
376
+ failureReasons.push(`Top5 유지율 (${(top5Retention * 100).toFixed(1)}%) < 임계값 (${(top5RetentionThreshold * 100).toFixed(1)}%)`);
377
+ }
378
+ // 순서 보존 지표 생성
379
+ const metrics = {
380
+ kendallTau,
381
+ spearmanRho,
382
+ topKRetention,
383
+ top10Retention,
384
+ top5Retention,
385
+ totalResults: Math.max(pair.vectorOnly.length, pair.withConsolidation.length)
386
+ };
387
+ // 리포트 생성
388
+ const report = {
389
+ timestamp: new Date().toISOString(),
390
+ metrics,
391
+ passed,
392
+ failureReasons: passed ? undefined : failureReasons,
393
+ validation: {
394
+ kendallTauValid,
395
+ top10RetentionValid,
396
+ top5RetentionValid
397
+ },
398
+ thresholds: {
399
+ kendallTauThreshold,
400
+ top10RetentionThreshold,
401
+ top5RetentionThreshold
402
+ }
403
+ };
404
+ return report;
405
+ }
406
+ /**
407
+ * 벡터 유사도만 사용한 검색 결과에서 품질 지표 측정
408
+ * Ground Truth를 기반으로 Precision/Recall/NDCG를 계산합니다.
409
+ *
410
+ * @param results 벡터 유사도만으로 정렬된 검색 결과
411
+ * @param groundTruth Ground Truth (관련 결과 ID 목록)
412
+ * @param kValues 계산할 K 값 배열 (기본값: [1, 5, 10])
413
+ * @returns 품질 지표 (Precision/Recall/NDCG)
414
+ *
415
+ * @example
416
+ * ```typescript
417
+ * const vectorOnlyResults = generateVectorOnlySearchResults(searchResults);
418
+ * const groundTruth = { queryId: 'query1', relevantIds: ['id1', 'id2', 'id3'] };
419
+ * const metrics = measureVectorOnlyQuality(vectorOnlyResults, groundTruth);
420
+ * console.log(`NDCG@5: ${metrics.ndcg[5]}`);
421
+ * ```
422
+ */
423
+ export function measureVectorOnlyQuality(results, groundTruth, kValues = [1, 5, 10]) {
424
+ const metrics = {
425
+ precision: {},
426
+ recall: {},
427
+ ndcg: {}
428
+ };
429
+ kValues.forEach(k => {
430
+ metrics.precision[k] = calculatePrecisionAtK(results, groundTruth.relevantIds, k);
431
+ metrics.recall[k] = calculateRecallAtK(results, groundTruth.relevantIds, k);
432
+ metrics.ndcg[k] = calculateNDCGAtK(results, groundTruth.relevantIds, k);
433
+ });
434
+ return metrics;
435
+ }
436
+ /**
437
+ * Consolidation 점수 반영 후 검색 결과에서 품질 지표 측정
438
+ * Ground Truth를 기반으로 Precision/Recall/NDCG를 계산합니다.
439
+ *
440
+ * @param results Consolidation 점수 반영 후 정렬된 검색 결과
441
+ * @param groundTruth Ground Truth (관련 결과 ID 목록)
442
+ * @param kValues 계산할 K 값 배열 (기본값: [1, 5, 10])
443
+ * @returns 품질 지표 (Precision/Recall/NDCG)
444
+ *
445
+ * @example
446
+ * ```typescript
447
+ * const consolidationResults = generateConsolidationSearchResults(searchResults);
448
+ * const groundTruth = { queryId: 'query1', relevantIds: ['id1', 'id2', 'id3'] };
449
+ * const metrics = measureConsolidationQuality(consolidationResults, groundTruth);
450
+ * console.log(`NDCG@5: ${metrics.ndcg[5]}`);
451
+ * ```
452
+ */
453
+ export function measureConsolidationQuality(results, groundTruth, kValues = [1, 5, 10]) {
454
+ const metrics = {
455
+ precision: {},
456
+ recall: {},
457
+ ndcg: {}
458
+ };
459
+ kValues.forEach(k => {
460
+ metrics.precision[k] = calculatePrecisionAtK(results, groundTruth.relevantIds, k);
461
+ metrics.recall[k] = calculateRecallAtK(results, groundTruth.relevantIds, k);
462
+ metrics.ndcg[k] = calculateNDCGAtK(results, groundTruth.relevantIds, k);
463
+ });
464
+ return metrics;
465
+ }
466
+ /**
467
+ * 품질 저하율 계산
468
+ * 벡터-only 품질과 Consolidation 반영 후 품질을 비교하여 저하율을 계산합니다.
469
+ *
470
+ * 저하율 공식: (vectorOnly - consolidation) / vectorOnly
471
+ * - 양수: 품질 저하
472
+ * - 음수: 품질 개선
473
+ * - 0: 변화 없음
474
+ *
475
+ * @param vectorOnlyMetrics 벡터-only 품질 지표
476
+ * @param consolidationMetrics Consolidation 반영 후 품질 지표
477
+ * @param kValues 계산할 K 값 배열 (기본값: [1, 5, 10])
478
+ * @returns 품질 저하율 (K 값별)
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * const vectorOnlyMetrics = measureVectorOnlyQuality(vectorOnlyResults, groundTruth);
483
+ * const consolidationMetrics = measureConsolidationQuality(consolidationResults, groundTruth);
484
+ * const degradation = calculateQualityDegradation(vectorOnlyMetrics, consolidationMetrics);
485
+ * console.log(`NDCG@5 저하율: ${(degradation.ndcg[5] * 100).toFixed(2)}%`);
486
+ * ```
487
+ */
488
+ export function calculateQualityDegradation(vectorOnlyMetrics, consolidationMetrics, kValues = [1, 5, 10]) {
489
+ const degradation = {
490
+ precision: {},
491
+ recall: {},
492
+ ndcg: {}
493
+ };
494
+ kValues.forEach(k => {
495
+ const vectorPrecision = vectorOnlyMetrics.precision[k] || 0;
496
+ const consolidationPrecision = consolidationMetrics.precision[k] || 0;
497
+ const vectorRecall = vectorOnlyMetrics.recall[k] || 0;
498
+ const consolidationRecall = consolidationMetrics.recall[k] || 0;
499
+ const vectorNDCG = vectorOnlyMetrics.ndcg[k] || 0;
500
+ const consolidationNDCG = consolidationMetrics.ndcg[k] || 0;
501
+ // 저하율 계산: (vectorOnly - consolidation) / vectorOnly
502
+ // vectorOnly가 0이면 저하율을 0으로 처리 (나눗셈 방지)
503
+ degradation.precision[k] = vectorPrecision > 0
504
+ ? (vectorPrecision - consolidationPrecision) / vectorPrecision
505
+ : 0;
506
+ degradation.recall[k] = vectorRecall > 0
507
+ ? (vectorRecall - consolidationRecall) / vectorRecall
508
+ : 0;
509
+ degradation.ndcg[k] = vectorNDCG > 0
510
+ ? (vectorNDCG - consolidationNDCG) / vectorNDCG
511
+ : 0;
512
+ });
513
+ return degradation;
514
+ }
515
+ /**
516
+ * 품질 저하 임계값 검증
517
+ * 품질 저하율이 임계값을 초과하지 않는지 검증합니다.
518
+ *
519
+ * Acceptance Criteria:
520
+ * - NDCG@5 저하율 < 5%
521
+ * - Precision@5 저하율 < 10%
522
+ * - Recall@5 저하율 < 10%
523
+ *
524
+ * @param degradation 품질 저하율
525
+ * @param options 검증 옵션
526
+ * @param options.ndcg5Threshold NDCG@5 저하율 임계값 (기본값: 0.05 = 5%)
527
+ * @param options.precision5Threshold Precision@5 저하율 임계값 (기본값: 0.10 = 10%)
528
+ * @param options.recall5Threshold Recall@5 저하율 임계값 (기본값: 0.10 = 10%)
529
+ * @returns 검증 결과
530
+ *
531
+ * @example
532
+ * ```typescript
533
+ * const degradation = calculateQualityDegradation(vectorOnlyMetrics, consolidationMetrics);
534
+ * const validation = validateQualityThresholds(degradation);
535
+ * if (!validation.passed) {
536
+ * console.error('품질 저하 임계값 초과:', validation.failureReasons);
537
+ * }
538
+ * ```
539
+ */
540
+ export function validateQualityThresholds(degradation, options = {}) {
541
+ const { ndcg5Threshold = 0.05, // 5%
542
+ precision5Threshold = 0.10, // 10%
543
+ recall5Threshold = 0.10 // 10%
544
+ } = options;
545
+ // 저하율은 양수일 때 저하를 의미하므로, 절댓값을 사용하여 비교
546
+ const ndcg5Degradation = Math.abs(degradation.ndcg[5] || 0);
547
+ const precision5Degradation = Math.abs(degradation.precision[5] || 0);
548
+ const recall5Degradation = Math.abs(degradation.recall[5] || 0);
549
+ // 검증 수행
550
+ const ndcg5Valid = ndcg5Degradation < ndcg5Threshold;
551
+ const precision5Valid = precision5Degradation < precision5Threshold;
552
+ const recall5Valid = recall5Degradation < recall5Threshold;
553
+ // 전체 검증 통과 여부
554
+ const passed = ndcg5Valid && precision5Valid && recall5Valid;
555
+ // 실패 사유 수집
556
+ const failureReasons = [];
557
+ if (!ndcg5Valid) {
558
+ failureReasons.push(`NDCG@5 저하율 (${(ndcg5Degradation * 100).toFixed(2)}%) >= 임계값 (${(ndcg5Threshold * 100).toFixed(1)}%)`);
559
+ }
560
+ if (!precision5Valid) {
561
+ failureReasons.push(`Precision@5 저하율 (${(precision5Degradation * 100).toFixed(2)}%) >= 임계값 (${(precision5Threshold * 100).toFixed(1)}%)`);
562
+ }
563
+ if (!recall5Valid) {
564
+ failureReasons.push(`Recall@5 저하율 (${(recall5Degradation * 100).toFixed(2)}%) >= 임계값 (${(recall5Threshold * 100).toFixed(1)}%)`);
565
+ }
566
+ return {
567
+ passed,
568
+ failureReasons: passed ? undefined : failureReasons,
569
+ validation: {
570
+ ndcg5Valid,
571
+ precision5Valid,
572
+ recall5Valid
573
+ },
574
+ degradation: {
575
+ ndcg5: degradation.ndcg[5] || 0,
576
+ precision5: degradation.precision[5] || 0,
577
+ recall5: degradation.recall[5] || 0
578
+ }
579
+ };
580
+ }
581
+ /**
582
+ * Ground Truth 기반 품질 비교
583
+ * 벡터-only 결과와 Consolidation 반영 후 결과를 Ground Truth와 비교하여 품질을 측정하고 비교합니다.
584
+ *
585
+ * @param vectorOnlyResults 벡터 유사도만으로 정렬된 검색 결과
586
+ * @param consolidationResults Consolidation 점수 반영 후 정렬된 검색 결과
587
+ * @param groundTruth Ground Truth (관련 결과 ID 목록)
588
+ * @param kValues 계산할 K 값 배열 (기본값: [1, 5, 10])
589
+ * @param thresholdOptions 품질 저하 임계값 검증 옵션
590
+ * @returns 품질 비교 결과
591
+ *
592
+ * @example
593
+ * ```typescript
594
+ * const vectorOnlyResults = generateVectorOnlySearchResults(searchResults);
595
+ * const consolidationResults = generateConsolidationSearchResults(searchResults);
596
+ * const groundTruth = { queryId: 'query1', relevantIds: ['id1', 'id2', 'id3'] };
597
+ * const comparison = compareQualityWithGroundTruth(
598
+ * vectorOnlyResults,
599
+ * consolidationResults,
600
+ * groundTruth
601
+ * );
602
+ * console.log(`벡터-only NDCG@5: ${comparison.vectorOnly.ndcg[5]}`);
603
+ * console.log(`Consolidation NDCG@5: ${comparison.consolidation.ndcg[5]}`);
604
+ * console.log(`검증 통과: ${comparison.thresholdValidation.passed}`);
605
+ * ```
606
+ */
607
+ export function compareQualityWithGroundTruth(vectorOnlyResults, consolidationResults, groundTruth, kValues = [1, 5, 10], thresholdOptions = {}) {
608
+ // 벡터-only 품질 측정
609
+ const vectorOnlyMetrics = measureVectorOnlyQuality(vectorOnlyResults, groundTruth, kValues);
610
+ // Consolidation 반영 후 품질 측정
611
+ const consolidationMetrics = measureConsolidationQuality(consolidationResults, groundTruth, kValues);
612
+ // 품질 저하율 계산
613
+ const degradation = calculateQualityDegradation(vectorOnlyMetrics, consolidationMetrics, kValues);
614
+ // 품질 저하 임계값 검증
615
+ const thresholdValidation = validateQualityThresholds(degradation, thresholdOptions);
616
+ return {
617
+ vectorOnly: vectorOnlyMetrics,
618
+ consolidation: consolidationMetrics,
619
+ degradation,
620
+ thresholdValidation
621
+ };
622
+ }
623
+ /**
624
+ * 품질 비교 결과 리포트 생성
625
+ * Ground Truth 기반 품질 비교 결과를 구조화된 리포트 형식으로 생성합니다.
626
+ *
627
+ * @param comparison 품질 비교 결과
628
+ * @param groundTruth Ground Truth 정보
629
+ * @returns 품질 비교 결과 리포트
630
+ *
631
+ * @example
632
+ * ```typescript
633
+ * const comparison = compareQualityWithGroundTruth(
634
+ * vectorOnlyResults,
635
+ * consolidationResults,
636
+ * groundTruth
637
+ * );
638
+ * const report = generateQualityComparisonReport(comparison, groundTruth);
639
+ * console.log(JSON.stringify(report, null, 2));
640
+ * ```
641
+ */
642
+ export function generateQualityComparisonReport(comparison, groundTruth) {
643
+ const report = {
644
+ timestamp: new Date().toISOString(),
645
+ groundTruth: {
646
+ queryId: groundTruth.queryId,
647
+ relevantIdsCount: groundTruth.relevantIds.length
648
+ },
649
+ vectorOnly: comparison.vectorOnly,
650
+ consolidation: comparison.consolidation,
651
+ degradation: comparison.degradation,
652
+ thresholdValidation: comparison.thresholdValidation,
653
+ summary: {
654
+ passed: comparison.thresholdValidation.passed,
655
+ keyMetrics: {
656
+ vectorOnlyNDCG5: comparison.vectorOnly.ndcg[5] || 0,
657
+ consolidationNDCG5: comparison.consolidation.ndcg[5] || 0,
658
+ ndcg5Degradation: Math.abs(comparison.degradation.ndcg[5] || 0)
659
+ }
660
+ }
661
+ };
662
+ return report;
663
+ }
664
+ /**
665
+ * 품질 비교 결과 시각화
666
+ * 품질 비교 결과를 Markdown 표 형식으로 시각화합니다.
667
+ *
668
+ * @param report 품질 비교 결과 리포트
669
+ * @param options 시각화 옵션
670
+ * @param options.kValues 표시할 K 값 배열 (기본값: [1, 5, 10])
671
+ * @param options.includeDegradation 저하율 포함 여부 (기본값: true)
672
+ * @returns Markdown 형식의 시각화된 리포트
673
+ *
674
+ * @example
675
+ * ```typescript
676
+ * const report = generateQualityComparisonReport(comparison, groundTruth);
677
+ * const visualization = visualizeQualityComparison(report);
678
+ * console.log(visualization);
679
+ * ```
680
+ */
681
+ export function visualizeQualityComparison(report, options = {}) {
682
+ const { kValues = [1, 5, 10], includeDegradation = true } = options;
683
+ const lines = [];
684
+ // 헤더
685
+ lines.push('# 품질 비교 결과 리포트');
686
+ lines.push('');
687
+ lines.push(`**생성 시간**: ${report.timestamp}`);
688
+ lines.push(`**쿼리 ID**: ${report.groundTruth.queryId}`);
689
+ lines.push(`**관련 결과 수**: ${report.groundTruth.relevantIdsCount}`);
690
+ lines.push(`**검증 통과**: ${report.summary.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
691
+ lines.push('');
692
+ // 주요 지표 요약
693
+ lines.push('## 주요 지표 요약 (K=5)');
694
+ lines.push('');
695
+ lines.push('| 지표 | 벡터-only | Consolidation | 저하율 |');
696
+ lines.push('|------|-----------|---------------|--------|');
697
+ const ndcg5 = report.summary.keyMetrics;
698
+ const ndcg5DegradationPercent = (ndcg5.ndcg5Degradation * 100).toFixed(2);
699
+ lines.push(`| NDCG@5 | ${ndcg5.vectorOnlyNDCG5.toFixed(3)} | ${ndcg5.consolidationNDCG5.toFixed(3)} | ${ndcg5DegradationPercent}% |`);
700
+ lines.push('');
701
+ // 상세 품질 지표 표
702
+ lines.push('## 상세 품질 지표');
703
+ lines.push('');
704
+ // Precision 표
705
+ lines.push('### Precision@K');
706
+ lines.push('');
707
+ lines.push('| K | 벡터-only | Consolidation |' + (includeDegradation ? ' 저하율 |' : ''));
708
+ lines.push('|---|-----------|---------------|' + (includeDegradation ? '--------|' : ''));
709
+ kValues.forEach(k => {
710
+ const vectorPrecision = report.vectorOnly.precision[k] || 0;
711
+ const consolidationPrecision = report.consolidation.precision[k] || 0;
712
+ const degradation = report.degradation.precision[k] || 0;
713
+ const degradationPercent = (Math.abs(degradation) * 100).toFixed(2);
714
+ const degradationSign = degradation >= 0 ? '' : '+';
715
+ if (includeDegradation) {
716
+ lines.push(`| ${k} | ${vectorPrecision.toFixed(3)} | ${consolidationPrecision.toFixed(3)} | ${degradationSign}${degradationPercent}% |`);
717
+ }
718
+ else {
719
+ lines.push(`| ${k} | ${vectorPrecision.toFixed(3)} | ${consolidationPrecision.toFixed(3)} |`);
720
+ }
721
+ });
722
+ lines.push('');
723
+ // Recall 표
724
+ lines.push('### Recall@K');
725
+ lines.push('');
726
+ lines.push('| K | 벡터-only | Consolidation |' + (includeDegradation ? ' 저하율 |' : ''));
727
+ lines.push('|---|-----------|---------------|' + (includeDegradation ? '--------|' : ''));
728
+ kValues.forEach(k => {
729
+ const vectorRecall = report.vectorOnly.recall[k] || 0;
730
+ const consolidationRecall = report.consolidation.recall[k] || 0;
731
+ const degradation = report.degradation.recall[k] || 0;
732
+ const degradationPercent = (Math.abs(degradation) * 100).toFixed(2);
733
+ const degradationSign = degradation >= 0 ? '' : '+';
734
+ if (includeDegradation) {
735
+ lines.push(`| ${k} | ${vectorRecall.toFixed(3)} | ${consolidationRecall.toFixed(3)} | ${degradationSign}${degradationPercent}% |`);
736
+ }
737
+ else {
738
+ lines.push(`| ${k} | ${vectorRecall.toFixed(3)} | ${consolidationRecall.toFixed(3)} |`);
739
+ }
740
+ });
741
+ lines.push('');
742
+ // NDCG 표
743
+ lines.push('### NDCG@K');
744
+ lines.push('');
745
+ lines.push('| K | 벡터-only | Consolidation |' + (includeDegradation ? ' 저하율 |' : ''));
746
+ lines.push('|---|-----------|---------------|' + (includeDegradation ? '--------|' : ''));
747
+ kValues.forEach(k => {
748
+ const vectorNDCG = report.vectorOnly.ndcg[k] || 0;
749
+ const consolidationNDCG = report.consolidation.ndcg[k] || 0;
750
+ const degradation = report.degradation.ndcg[k] || 0;
751
+ const degradationPercent = (Math.abs(degradation) * 100).toFixed(2);
752
+ const degradationSign = degradation >= 0 ? '' : '+';
753
+ if (includeDegradation) {
754
+ lines.push(`| ${k} | ${vectorNDCG.toFixed(3)} | ${consolidationNDCG.toFixed(3)} | ${degradationSign}${degradationPercent}% |`);
755
+ }
756
+ else {
757
+ lines.push(`| ${k} | ${vectorNDCG.toFixed(3)} | ${consolidationNDCG.toFixed(3)} |`);
758
+ }
759
+ });
760
+ lines.push('');
761
+ // 검증 결과
762
+ lines.push('## 검증 결과');
763
+ lines.push('');
764
+ const validation = report.thresholdValidation;
765
+ lines.push('| 지표 | 임계값 | 실제 값 | 상태 |');
766
+ lines.push('|------|--------|---------|------|');
767
+ const ndcg5Deg = Math.abs(validation.degradation.ndcg5);
768
+ const precision5Deg = Math.abs(validation.degradation.precision5);
769
+ const recall5Deg = Math.abs(validation.degradation.recall5);
770
+ const ndcg5Status = validation.validation.ndcg5Valid ? '[PASS] 통과' : '[FAIL] 실패';
771
+ const precision5Status = validation.validation.precision5Valid ? '[PASS] 통과' : '[FAIL] 실패';
772
+ const recall5Status = validation.validation.recall5Valid ? '[PASS] 통과' : '[FAIL] 실패';
773
+ lines.push(`| NDCG@5 저하율 | < 5% | ${(ndcg5Deg * 100).toFixed(2)}% | ${ndcg5Status} |`);
774
+ lines.push(`| Precision@5 저하율 | < 10% | ${(precision5Deg * 100).toFixed(2)}% | ${precision5Status} |`);
775
+ lines.push(`| Recall@5 저하율 | < 10% | ${(recall5Deg * 100).toFixed(2)}% | ${recall5Status} |`);
776
+ lines.push('');
777
+ // 실패 사유
778
+ if (validation.failureReasons && validation.failureReasons.length > 0) {
779
+ lines.push('### 실패 사유');
780
+ lines.push('');
781
+ validation.failureReasons.forEach(reason => {
782
+ lines.push(`- [FAIL] ${reason}`);
783
+ });
784
+ lines.push('');
785
+ }
786
+ return lines.join('\n');
787
+ }
788
+ /**
789
+ * 저벡터 유사도 + 고 consolidation 점수 시나리오 검증
790
+ * 벡터 유사도는 낮지만 consolidation 점수가 매우 높은 경우의 랭킹을 검증합니다.
791
+ *
792
+ * 예: 벡터 유사도 0.3, consolidation 0.9
793
+ * 최종 점수가 합리적인 범위 내인지 검증합니다.
794
+ *
795
+ * @param results 검색 결과 (HybridSearchResult 배열)
796
+ * @param options 검증 옵션
797
+ * @param options.lowVectorThreshold 저벡터 유사도 임계값 (기본값: 0.4)
798
+ * @param options.highConsolidationThreshold 고 consolidation 점수 임계값 (기본값: 0.7)
799
+ * @param options.minFinalScore 최종 점수 최소값 (기본값: 0.0)
800
+ * @param options.maxFinalScore 최종 점수 최대값 (기본값: 1.0)
801
+ * @returns 검증 결과
802
+ *
803
+ * @example
804
+ * ```typescript
805
+ * const results = await hybridSearchEngine.search(query, options);
806
+ * const validation = validateLowVectorHighConsolidation(results);
807
+ * if (!validation.passed) {
808
+ * console.error('극단적 시나리오 검증 실패:', validation.failureReasons);
809
+ * }
810
+ * ```
811
+ */
812
+ export function validateLowVectorHighConsolidation(results, options = {}) {
813
+ const { lowVectorThreshold = 0.4, highConsolidationThreshold = 0.7, minFinalScore = 0.0, maxFinalScore = 1.0 } = options;
814
+ // 저벡터 유사도 + 고 consolidation 점수 조합 필터링
815
+ const extremeResults = results.filter(result => {
816
+ const vectorScore = result.vectorScore || 0;
817
+ const consolidationScore = result.consolidation_score || 0;
818
+ return vectorScore < lowVectorThreshold && consolidationScore >= highConsolidationThreshold;
819
+ });
820
+ if (extremeResults.length === 0) {
821
+ return {
822
+ passed: true,
823
+ finalScoreRange: { min: 0, max: 0, average: 0 },
824
+ vectorSimilarityStats: { min: 0, max: 0, average: 0 },
825
+ consolidationScoreStats: { min: 0, max: 0, average: 0 }
826
+ };
827
+ }
828
+ // 통계 계산
829
+ const finalScores = extremeResults.map(r => r.finalScore || 0);
830
+ const vectorScores = extremeResults.map(r => r.vectorScore || 0);
831
+ const consolidationScores = extremeResults.map(r => r.consolidation_score || 0);
832
+ const finalScoreRange = {
833
+ min: Math.min(...finalScores),
834
+ max: Math.max(...finalScores),
835
+ average: finalScores.reduce((sum, score) => sum + score, 0) / finalScores.length
836
+ };
837
+ const vectorSimilarityStats = {
838
+ min: Math.min(...vectorScores),
839
+ max: Math.max(...vectorScores),
840
+ average: vectorScores.reduce((sum, score) => sum + score, 0) / vectorScores.length
841
+ };
842
+ const consolidationScoreStats = {
843
+ min: Math.min(...consolidationScores),
844
+ max: Math.max(...consolidationScores),
845
+ average: consolidationScores.reduce((sum, score) => sum + score, 0) / consolidationScores.length
846
+ };
847
+ // 검증 수행: 최종 점수가 합리적인 범위 내인지 확인
848
+ const passed = finalScoreRange.min >= minFinalScore && finalScoreRange.max <= maxFinalScore;
849
+ const failureReasons = [];
850
+ if (finalScoreRange.min < minFinalScore) {
851
+ failureReasons.push(`최종 점수 최소값 (${finalScoreRange.min.toFixed(3)}) < 임계값 (${minFinalScore})`);
852
+ }
853
+ if (finalScoreRange.max > maxFinalScore) {
854
+ failureReasons.push(`최종 점수 최대값 (${finalScoreRange.max.toFixed(3)}) > 임계값 (${maxFinalScore})`);
855
+ }
856
+ return {
857
+ passed,
858
+ failureReasons: passed ? undefined : failureReasons,
859
+ finalScoreRange,
860
+ vectorSimilarityStats,
861
+ consolidationScoreStats
862
+ };
863
+ }
864
+ /**
865
+ * 고벡터 유사도 + 저 consolidation 점수 시나리오 검증
866
+ * 벡터 유사도는 높지만 consolidation 점수가 낮은 경우의 랭킹을 검증합니다.
867
+ *
868
+ * 예: 벡터 유사도 0.9, consolidation 0.1
869
+ * 벡터 유사도가 우선 반영되는지 검증합니다.
870
+ *
871
+ * @param results 검색 결과 (HybridSearchResult 배열)
872
+ * @param options 검증 옵션
873
+ * @param options.highVectorThreshold 고벡터 유사도 임계값 (기본값: 0.7)
874
+ * @param options.lowConsolidationThreshold 저 consolidation 점수 임계값 (기본값: 0.3)
875
+ * @param options.vectorPriorityRatio 벡터 유사도가 최종 점수에 미치는 최소 영향 비율 (기본값: 0.6)
876
+ * @returns 검증 결과
877
+ *
878
+ * @example
879
+ * ```typescript
880
+ * const results = await hybridSearchEngine.search(query, options);
881
+ * const validation = validateHighVectorLowConsolidation(results);
882
+ * if (!validation.passed) {
883
+ * console.error('극단적 시나리오 검증 실패:', validation.failureReasons);
884
+ * }
885
+ * ```
886
+ */
887
+ export function validateHighVectorLowConsolidation(results, options = {}) {
888
+ const { highVectorThreshold = 0.7, lowConsolidationThreshold = 0.3, vectorPriorityRatio = 0.6 // 벡터 유사도가 최종 점수의 최소 60%를 차지해야 함
889
+ } = options;
890
+ // 고벡터 유사도 + 저 consolidation 점수 조합 필터링
891
+ const extremeResults = results.filter(result => {
892
+ const vectorScore = result.vectorScore || 0;
893
+ const consolidationScore = result.consolidation_score || 0;
894
+ return vectorScore >= highVectorThreshold && consolidationScore < lowConsolidationThreshold;
895
+ });
896
+ if (extremeResults.length === 0) {
897
+ return {
898
+ passed: true,
899
+ finalScoreRange: { min: 0, max: 0, average: 0 },
900
+ vectorSimilarityStats: { min: 0, max: 0, average: 0 },
901
+ consolidationScoreStats: { min: 0, max: 0, average: 0 }
902
+ };
903
+ }
904
+ // 통계 계산
905
+ const finalScores = extremeResults.map(r => r.finalScore || 0);
906
+ const vectorScores = extremeResults.map(r => r.vectorScore || 0);
907
+ const consolidationScores = extremeResults.map(r => r.consolidation_score || 0);
908
+ const finalScoreRange = {
909
+ min: Math.min(...finalScores),
910
+ max: Math.max(...finalScores),
911
+ average: finalScores.reduce((sum, score) => sum + score, 0) / finalScores.length
912
+ };
913
+ const vectorSimilarityStats = {
914
+ min: Math.min(...vectorScores),
915
+ max: Math.max(...vectorScores),
916
+ average: vectorScores.reduce((sum, score) => sum + score, 0) / vectorScores.length
917
+ };
918
+ const consolidationScoreStats = {
919
+ min: Math.min(...consolidationScores),
920
+ max: Math.max(...consolidationScores),
921
+ average: consolidationScores.reduce((sum, score) => sum + score, 0) / consolidationScores.length
922
+ };
923
+ // 검증 수행: 벡터 유사도가 최종 점수에 충분히 반영되는지 확인
924
+ // 최종 점수는 벡터 유사도에 비례해야 함 (w1 >= vectorPriorityRatio)
925
+ // 실제로는 finalScore = w1 * vectorScore + w2 * consolidationScore
926
+ // 벡터 유사도가 우선 반영되려면 finalScore가 vectorScore에 가까워야 함
927
+ const passed = extremeResults.every(result => {
928
+ const vectorScore = result.vectorScore || 0;
929
+ const finalScore = result.finalScore || 0;
930
+ // 벡터 유사도가 높은 경우, 최종 점수도 상대적으로 높아야 함
931
+ // 벡터 유사도가 최종 점수의 최소 vectorPriorityRatio 비율을 차지해야 함
932
+ if (vectorScore === 0)
933
+ return true; // 벡터 점수가 0이면 검증 불가
934
+ // 최종 점수가 벡터 유사도의 vectorPriorityRatio 이상이어야 함
935
+ // (최종 점수 / 벡터 유사도) >= vectorPriorityRatio
936
+ const scoreRatio = finalScore / vectorScore;
937
+ return scoreRatio >= vectorPriorityRatio;
938
+ });
939
+ const failureReasons = [];
940
+ if (!passed) {
941
+ const failedResults = extremeResults.filter(result => {
942
+ const vectorScore = result.vectorScore || 0;
943
+ const finalScore = result.finalScore || 0;
944
+ if (vectorScore === 0)
945
+ return false;
946
+ const scoreRatio = finalScore / vectorScore;
947
+ return scoreRatio < vectorPriorityRatio;
948
+ });
949
+ failureReasons.push(`${failedResults.length}개 결과에서 벡터 유사도가 최종 점수에 충분히 반영되지 않음 (최소 비율: ${(vectorPriorityRatio * 100).toFixed(0)}%)`);
950
+ }
951
+ return {
952
+ passed,
953
+ failureReasons: passed ? undefined : failureReasons,
954
+ finalScoreRange,
955
+ vectorSimilarityStats,
956
+ consolidationScoreStats
957
+ };
958
+ }
959
+ /**
960
+ * w2 상한(0.4) 검증
961
+ * w2=0.4일 때와 w2=0.6일 때의 품질을 비교하여 w2 상한이 벡터 검색 품질을 보호하는지 검증합니다.
962
+ *
963
+ * w2가 높을수록 consolidation 점수의 영향이 커지므로, w2=0.6일 때 품질이 저하되는지 확인합니다.
964
+ *
965
+ * @param originalResults 원본 검색 결과 (HybridSearchResult 배열, vectorScore와 consolidation_score 포함)
966
+ * @param groundTruth Ground Truth
967
+ * @param kValues 계산할 K 값 배열 (기본값: [1, 5, 10])
968
+ * @returns 검증 결과
969
+ *
970
+ * @example
971
+ * ```typescript
972
+ * const results = await hybridSearchEngine.search(query, options);
973
+ * const validation = validateW2UpperBound(results, groundTruth);
974
+ * console.log(`w2 상한 보호: ${validation.w2UpperBoundProtects}`);
975
+ * ```
976
+ */
977
+ export function validateW2UpperBound(originalResults, groundTruth, kValues = [1, 5, 10]) {
978
+ // w2=0.4일 때 최종 점수 재계산
979
+ // finalScore = w1 * vectorScore + w2 * consolidationScore
980
+ // w2=0.4일 때: w1=0.6, w2=0.4
981
+ const w2_04_results = originalResults
982
+ .filter(r => r.vectorScore !== undefined && r.consolidation_score !== undefined)
983
+ .map(r => {
984
+ const w1 = 0.6;
985
+ const w2 = 0.4;
986
+ const finalScore = w1 * (r.vectorScore || 0) + w2 * (r.consolidation_score || 0);
987
+ return {
988
+ id: r.id,
989
+ score: finalScore,
990
+ finalScore: finalScore,
991
+ relevance: r.vectorScore || 0
992
+ };
993
+ })
994
+ .sort((a, b) => (b.finalScore || 0) - (a.finalScore || 0));
995
+ // w2=0.6일 때 최종 점수 재계산
996
+ // w2=0.6일 때: w1=0.4, w2=0.6
997
+ const w2_06_results = originalResults
998
+ .filter(r => r.vectorScore !== undefined && r.consolidation_score !== undefined)
999
+ .map(r => {
1000
+ const w1 = 0.4;
1001
+ const w2 = 0.6;
1002
+ const finalScore = w1 * (r.vectorScore || 0) + w2 * (r.consolidation_score || 0);
1003
+ return {
1004
+ id: r.id,
1005
+ score: finalScore,
1006
+ finalScore: finalScore,
1007
+ relevance: r.vectorScore || 0
1008
+ };
1009
+ })
1010
+ .sort((a, b) => (b.finalScore || 0) - (a.finalScore || 0));
1011
+ // w2=0.4일 때 품질 측정
1012
+ const w2_04_metrics = measureConsolidationQuality(w2_04_results, groundTruth, kValues);
1013
+ // w2=0.6일 때 품질 측정
1014
+ const w2_06_metrics = measureConsolidationQuality(w2_06_results, groundTruth, kValues);
1015
+ // w2=0.4 대비 w2=0.6의 품질 저하율 계산
1016
+ const degradation_w2_06_vs_04 = calculateQualityDegradation(w2_04_metrics, w2_06_metrics, kValues);
1017
+ // w2 상한이 품질을 보호하는지 검증
1018
+ // w2=0.6일 때 w2=0.4 대비 실제 품질 저하가 발생하는지 확인
1019
+ // NDCG@5 저하율이 5% 이상이면 w2 상한이 필요함
1020
+ // 주의: degradation 값이 양수면 저하, 음수면 개선이므로 실제 저하만 확인
1021
+ const ndcg5Degradation = degradation_w2_06_vs_04.ndcg[5] || 0;
1022
+ const w2UpperBoundProtects = ndcg5Degradation >= 0.05;
1023
+ const passed = w2UpperBoundProtects;
1024
+ const failureReasons = [];
1025
+ if (!w2UpperBoundProtects) {
1026
+ const degradationPercent = ndcg5Degradation >= 0
1027
+ ? `${(ndcg5Degradation * 100).toFixed(2)}%`
1028
+ : `개선 ${(Math.abs(ndcg5Degradation) * 100).toFixed(2)}%`;
1029
+ failureReasons.push(`w2=0.6일 때 w2=0.4 대비 NDCG@5 변화 (${degradationPercent}) < 5% 저하 - w2 상한의 필요성이 낮음`);
1030
+ }
1031
+ return {
1032
+ passed,
1033
+ failureReasons: passed ? undefined : failureReasons,
1034
+ w2_04: w2_04_metrics,
1035
+ w2_06: w2_06_metrics,
1036
+ degradation: degradation_w2_06_vs_04,
1037
+ w2UpperBoundProtects
1038
+ };
1039
+ }
1040
+ /**
1041
+ * 극단적 시나리오 검증 결과 리포트 생성
1042
+ * 저벡터+고 consolidation, 고벡터+저 consolidation, w2 상한 검증 결과를 종합하여 리포트를 생성합니다.
1043
+ *
1044
+ * @param lowVectorHighConsolidation 저벡터 유사도 + 고 consolidation 점수 검증 결과
1045
+ * @param highVectorLowConsolidation 고벡터 유사도 + 저 consolidation 점수 검증 결과
1046
+ * @param w2UpperBound w2 상한 검증 결과
1047
+ * @returns 극단적 시나리오 검증 결과 리포트
1048
+ *
1049
+ * @example
1050
+ * ```typescript
1051
+ * const lowVectorHigh = validateLowVectorHighConsolidation(results);
1052
+ * const highVectorLow = validateHighVectorLowConsolidation(results);
1053
+ * const w2Validation = validateW2UpperBound(results, groundTruth);
1054
+ * const report = generateExtremeScenarioReport(
1055
+ * lowVectorHigh,
1056
+ * highVectorLow,
1057
+ * w2Validation
1058
+ * );
1059
+ * console.log(`전체 검증 통과: ${report.overallPassed}`);
1060
+ * ```
1061
+ */
1062
+ export function generateExtremeScenarioReport(lowVectorHighConsolidation, highVectorLowConsolidation, w2UpperBound) {
1063
+ const passedScenarios = [];
1064
+ const failedScenarios = [];
1065
+ if (lowVectorHighConsolidation.passed) {
1066
+ passedScenarios.push('저벡터 유사도 + 고 consolidation 점수');
1067
+ }
1068
+ else {
1069
+ failedScenarios.push('저벡터 유사도 + 고 consolidation 점수');
1070
+ }
1071
+ if (highVectorLowConsolidation.passed) {
1072
+ passedScenarios.push('고벡터 유사도 + 저 consolidation 점수');
1073
+ }
1074
+ else {
1075
+ failedScenarios.push('고벡터 유사도 + 저 consolidation 점수');
1076
+ }
1077
+ if (w2UpperBound.passed) {
1078
+ passedScenarios.push('w2 상한 검증');
1079
+ }
1080
+ else {
1081
+ failedScenarios.push('w2 상한 검증');
1082
+ }
1083
+ const overallPassed = lowVectorHighConsolidation.passed &&
1084
+ highVectorLowConsolidation.passed &&
1085
+ w2UpperBound.passed;
1086
+ return {
1087
+ timestamp: new Date().toISOString(),
1088
+ lowVectorHighConsolidation,
1089
+ highVectorLowConsolidation,
1090
+ w2UpperBound,
1091
+ overallPassed,
1092
+ summary: {
1093
+ passedCount: passedScenarios.length,
1094
+ totalCount: 3,
1095
+ failedScenarios
1096
+ }
1097
+ };
1098
+ }
1099
+ /**
1100
+ * Baseline 스냅샷 저장
1101
+ * Baseline 스냅샷을 JSON 형식으로 파일에 저장합니다.
1102
+ *
1103
+ * @param snapshot 저장할 Baseline 스냅샷
1104
+ * @param filePath 저장할 파일 경로 (기본값: `data/vector-search-quality-baseline.json`)
1105
+ * @throws 파일 저장 실패 시 에러 발생
1106
+ *
1107
+ * @example
1108
+ * ```typescript
1109
+ * const snapshot: BaselineSnapshot = {
1110
+ * version: '1.0.0',
1111
+ * timestamp: new Date().toISOString(),
1112
+ * testConfiguration: { dataSize: 100, weights: { vectorSimilarity: 0.6, consolidationScore: 0.4 } },
1113
+ * metrics: {
1114
+ * orderPreservation: { kendallTau: 0.85, top10Retention: 0.9, top5Retention: 0.95 },
1115
+ * quality: { precision: {}, recall: {}, ndcg: {} },
1116
+ * extremeScenarios: { lowVectorHighConsolidation: 1, highVectorLowConsolidation: 1 }
1117
+ * }
1118
+ * };
1119
+ * saveBaselineSnapshot(snapshot);
1120
+ * ```
1121
+ */
1122
+ export function saveBaselineSnapshot(snapshot, filePath) {
1123
+ // 기본 파일 경로 설정
1124
+ const defaultPath = join(__dirname, '../../../data/vector-search-quality-baseline.json');
1125
+ const targetPath = filePath || defaultPath;
1126
+ // 디렉토리 생성 (없는 경우)
1127
+ const dir = dirname(targetPath);
1128
+ if (!existsSync(dir)) {
1129
+ mkdirSync(dir, { recursive: true });
1130
+ }
1131
+ try {
1132
+ // JSON 형식으로 직렬화하여 저장
1133
+ const jsonContent = JSON.stringify(snapshot, null, 2);
1134
+ writeFileSync(targetPath, jsonContent, 'utf-8');
1135
+ }
1136
+ catch (error) {
1137
+ throw new Error(`Baseline 스냅샷 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Baseline 스냅샷 로드
1142
+ * 저장된 Baseline 스냅샷을 파일에서 로드합니다.
1143
+ *
1144
+ * @param filePath 로드할 파일 경로 (기본값: `data/vector-search-quality-baseline.json`)
1145
+ * @returns 로드된 Baseline 스냅샷 또는 null (파일이 없거나 로드 실패 시)
1146
+ *
1147
+ * @example
1148
+ * ```typescript
1149
+ * const snapshot = loadBaselineSnapshot();
1150
+ * if (snapshot) {
1151
+ * console.log(`Baseline 버전: ${snapshot.version}`);
1152
+ * console.log(`Baseline 생성 시간: ${snapshot.timestamp}`);
1153
+ * } else {
1154
+ * console.log('Baseline 스냅샷이 없습니다.');
1155
+ * }
1156
+ * ```
1157
+ */
1158
+ export function loadBaselineSnapshot(filePath) {
1159
+ // 기본 파일 경로 설정
1160
+ const defaultPath = join(__dirname, '../../../data/vector-search-quality-baseline.json');
1161
+ const targetPath = filePath || defaultPath;
1162
+ // 파일 존재 여부 확인
1163
+ if (!existsSync(targetPath)) {
1164
+ return null;
1165
+ }
1166
+ try {
1167
+ // 파일 읽기
1168
+ const content = readFileSync(targetPath, 'utf-8');
1169
+ // JSON 파싱
1170
+ const snapshot = JSON.parse(content);
1171
+ // 기본 검증 (필수 필드 존재 여부)
1172
+ if (!snapshot.version || !snapshot.timestamp || !snapshot.metrics) {
1173
+ throw new Error('Baseline 스냅샷 형식이 올바르지 않습니다.');
1174
+ }
1175
+ return snapshot;
1176
+ }
1177
+ catch (error) {
1178
+ // 로드 실패 시 null 반환 (에러 로깅은 호출자가 처리)
1179
+ return null;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Baseline과 현재 결과 비교
1184
+ * Baseline 스냅샷과 현재 검증 결과를 비교하여 품질 저하를 감지합니다.
1185
+ *
1186
+ * @param baseline Baseline 스냅샷
1187
+ * @param currentOrderPreservation 현재 순서 보존 검증 결과
1188
+ * @param currentQuality 현재 품질 지표 (QualityMetrics)
1189
+ * @param currentExtremeScenarios 현재 극단적 시나리오 검증 결과
1190
+ * @param kValues 비교할 K 값 배열 (기본값: [1, 5, 10])
1191
+ * @returns Baseline 비교 결과
1192
+ *
1193
+ * @example
1194
+ * ```typescript
1195
+ * const baseline = loadBaselineSnapshot();
1196
+ * if (baseline) {
1197
+ * const comparison = compareWithBaseline(
1198
+ * baseline,
1199
+ * orderPreservationReport,
1200
+ * qualityMetrics,
1201
+ * extremeScenarioReport
1202
+ * );
1203
+ * if (comparison.hasDegradation) {
1204
+ * console.warn('품질 저하 감지:', comparison.degradationDetails);
1205
+ * }
1206
+ * }
1207
+ * ```
1208
+ */
1209
+ export function compareWithBaseline(baseline, currentOrderPreservation, currentQuality, currentExtremeScenarios, kValues = [1, 5, 10]) {
1210
+ const degradationDetails = [];
1211
+ // 순서 보존 지표 비교
1212
+ const baselineOrderPreservation = baseline.metrics.orderPreservation;
1213
+ if (!baselineOrderPreservation) {
1214
+ throw new Error('Baseline orderPreservation metrics are missing');
1215
+ }
1216
+ const kendallTauChange = currentOrderPreservation.metrics.kendallTau - baselineOrderPreservation.kendallTau;
1217
+ const top10RetentionChange = (currentOrderPreservation.metrics.topKRetention[10] || 0) - baselineOrderPreservation.top10Retention;
1218
+ const top5RetentionChange = (currentOrderPreservation.metrics.topKRetention[5] || 0) - baselineOrderPreservation.top5Retention;
1219
+ // 순서 보존 지표 저하 감지
1220
+ if (kendallTauChange < -0.1) {
1221
+ degradationDetails.push(`Kendall's Tau 저하: ${kendallTauChange.toFixed(3)}`);
1222
+ }
1223
+ if (top10RetentionChange < -0.1) {
1224
+ degradationDetails.push(`Top10 유지율 저하: ${top10RetentionChange.toFixed(3)}`);
1225
+ }
1226
+ if (top5RetentionChange < -0.1) {
1227
+ degradationDetails.push(`Top5 유지율 저하: ${top5RetentionChange.toFixed(3)}`);
1228
+ }
1229
+ // 품질 지표 비교
1230
+ const precisionChange = {};
1231
+ const recallChange = {};
1232
+ const ndcgChange = {};
1233
+ kValues.forEach(k => {
1234
+ const baselinePrecision = baseline.metrics.quality.precision[k] || 0;
1235
+ const currentPrecision = currentQuality.precision[k] || 0;
1236
+ const precisionDiff = baselinePrecision > 0
1237
+ ? (currentPrecision - baselinePrecision) / baselinePrecision
1238
+ : 0;
1239
+ precisionChange[k] = precisionDiff;
1240
+ const baselineRecall = baseline.metrics.quality.recall[k] || 0;
1241
+ const currentRecall = currentQuality.recall[k] || 0;
1242
+ const recallDiff = baselineRecall > 0
1243
+ ? (currentRecall - baselineRecall) / baselineRecall
1244
+ : 0;
1245
+ recallChange[k] = recallDiff;
1246
+ const baselineNDCG = baseline.metrics.quality.ndcg[k] || 0;
1247
+ const currentNDCG = currentQuality.ndcg[k] || 0;
1248
+ const ndcgDiff = baselineNDCG > 0
1249
+ ? (currentNDCG - baselineNDCG) / baselineNDCG
1250
+ : 0;
1251
+ ndcgChange[k] = ndcgDiff;
1252
+ // 품질 지표 저하 감지 (5% 이상 저하)
1253
+ if (ndcgDiff < -0.05) {
1254
+ degradationDetails.push(`NDCG@${k} 저하: ${(ndcgDiff * 100).toFixed(2)}%`);
1255
+ }
1256
+ if (precisionDiff < -0.10) {
1257
+ degradationDetails.push(`Precision@${k} 저하: ${(precisionDiff * 100).toFixed(2)}%`);
1258
+ }
1259
+ if (recallDiff < -0.10) {
1260
+ degradationDetails.push(`Recall@${k} 저하: ${(recallDiff * 100).toFixed(2)}%`);
1261
+ }
1262
+ });
1263
+ // 극단적 시나리오 검증 비교
1264
+ const lowVectorHighConsolidationChange = (currentExtremeScenarios.lowVectorHighConsolidation.passed ? 1 : 0) -
1265
+ baseline.metrics.extremeScenarios.lowVectorHighConsolidation;
1266
+ const highVectorLowConsolidationChange = (currentExtremeScenarios.highVectorLowConsolidation.passed ? 1 : 0) -
1267
+ baseline.metrics.extremeScenarios.highVectorLowConsolidation;
1268
+ // 극단적 시나리오 검증 저하 감지
1269
+ if (lowVectorHighConsolidationChange < 0) {
1270
+ degradationDetails.push('저벡터 유사도 + 고 consolidation 점수 검증 실패');
1271
+ }
1272
+ if (highVectorLowConsolidationChange < 0) {
1273
+ degradationDetails.push('고벡터 유사도 + 저 consolidation 점수 검증 실패');
1274
+ }
1275
+ // 전체 품질 저하 여부 판단
1276
+ const hasDegradation = degradationDetails.length > 0;
1277
+ return {
1278
+ baseline: {
1279
+ version: baseline.version,
1280
+ timestamp: baseline.timestamp
1281
+ },
1282
+ orderPreservation: {
1283
+ kendallTauChange,
1284
+ top10RetentionChange,
1285
+ top5RetentionChange
1286
+ },
1287
+ quality: {
1288
+ precisionChange,
1289
+ recallChange,
1290
+ ndcgChange
1291
+ },
1292
+ extremeScenarios: {
1293
+ lowVectorHighConsolidationChange,
1294
+ highVectorLowConsolidationChange
1295
+ },
1296
+ hasDegradation,
1297
+ degradationDetails: hasDegradation ? degradationDetails : []
1298
+ };
1299
+ }
1300
+ /**
1301
+ * 품질 저하 감지 및 알림
1302
+ * Baseline 비교 결과를 분석하여 품질 저하를 감지하고 알림을 생성합니다.
1303
+ *
1304
+ * @param comparison Baseline 비교 결과
1305
+ * @param options 감지 옵션
1306
+ * @param options.ndcg5Threshold NDCG@5 저하 임계값 (기본값: 0.05 = 5%)
1307
+ * @param options.precision5Threshold Precision@5 저하 임계값 (기본값: 0.10 = 10%)
1308
+ * @param options.recall5Threshold Recall@5 저하 임계값 (기본값: 0.10 = 10%)
1309
+ * @param options.kendallTauThreshold Kendall's Tau 저하 임계값 (기본값: 0.1)
1310
+ * @param options.criticalThreshold 심각한 저하 임계값 (기본값: 0.20 = 20%)
1311
+ * @returns 품질 저하 감지 결과
1312
+ *
1313
+ * @example
1314
+ * ```typescript
1315
+ * const comparison = compareWithBaseline(baseline, ...);
1316
+ * const detection = detectQualityDegradation(comparison);
1317
+ * if (detection.detected) {
1318
+ * console.warn(`[${detection.severity.toUpperCase()}] 품질 저하 감지:`);
1319
+ * detection.messages.forEach(msg => console.warn(` - ${msg}`));
1320
+ * }
1321
+ * ```
1322
+ */
1323
+ export function detectQualityDegradation(comparison, options = {}) {
1324
+ const { ndcg5Threshold = 0.05, // 5%
1325
+ precision5Threshold = 0.10, // 10%
1326
+ recall5Threshold = 0.10, // 10%
1327
+ kendallTauThreshold = 0.1, criticalThreshold = 0.20 // 20%
1328
+ } = options;
1329
+ const messages = [];
1330
+ const recommendations = [];
1331
+ let severity = 'none';
1332
+ let hasWarning = false;
1333
+ let hasCritical = false;
1334
+ // 순서 보존 지표 저하 감지
1335
+ if (comparison.orderPreservation.kendallTauChange < -kendallTauThreshold) {
1336
+ const change = comparison.orderPreservation.kendallTauChange;
1337
+ const isCritical = change < -criticalThreshold;
1338
+ messages.push(`Kendall's Tau 저하: ${change.toFixed(3)} (Baseline: ${(comparison.baseline.version)} 기준)`);
1339
+ if (isCritical) {
1340
+ hasCritical = true;
1341
+ recommendations.push('순서 보존 지표가 크게 저하되었습니다. 가중치 설정을 재검토하세요.');
1342
+ }
1343
+ else {
1344
+ hasWarning = true;
1345
+ recommendations.push('순서 보존 지표가 저하되었습니다. 모니터링을 강화하세요.');
1346
+ }
1347
+ }
1348
+ if (comparison.orderPreservation.top10RetentionChange < -0.1) {
1349
+ const change = comparison.orderPreservation.top10RetentionChange;
1350
+ const isCritical = change < -criticalThreshold;
1351
+ messages.push(`Top10 유지율 저하: ${(change * 100).toFixed(2)}% (Baseline: ${comparison.baseline.version} 기준)`);
1352
+ if (isCritical) {
1353
+ hasCritical = true;
1354
+ }
1355
+ else {
1356
+ hasWarning = true;
1357
+ }
1358
+ }
1359
+ if (comparison.orderPreservation.top5RetentionChange < -0.1) {
1360
+ const change = comparison.orderPreservation.top5RetentionChange;
1361
+ const isCritical = change < -criticalThreshold;
1362
+ messages.push(`Top5 유지율 저하: ${(change * 100).toFixed(2)}% (Baseline: ${comparison.baseline.version} 기준)`);
1363
+ if (isCritical) {
1364
+ hasCritical = true;
1365
+ }
1366
+ else {
1367
+ hasWarning = true;
1368
+ }
1369
+ }
1370
+ // 품질 지표 저하 감지
1371
+ const ndcg5Change = comparison.quality.ndcgChange[5] || 0;
1372
+ if (ndcg5Change < -ndcg5Threshold) {
1373
+ const isCritical = ndcg5Change < -criticalThreshold;
1374
+ messages.push(`NDCG@5 저하: ${(ndcg5Change * 100).toFixed(2)}% (Baseline: ${comparison.baseline.version} 기준)`);
1375
+ if (isCritical) {
1376
+ hasCritical = true;
1377
+ recommendations.push('NDCG@5가 크게 저하되었습니다. 검색 알고리즘을 재검토하세요.');
1378
+ }
1379
+ else {
1380
+ hasWarning = true;
1381
+ recommendations.push('NDCG@5가 저하되었습니다. 가중치 조정을 고려하세요.');
1382
+ }
1383
+ }
1384
+ const precision5Change = comparison.quality.precisionChange[5] || 0;
1385
+ if (precision5Change < -precision5Threshold) {
1386
+ const isCritical = precision5Change < -criticalThreshold;
1387
+ messages.push(`Precision@5 저하: ${(precision5Change * 100).toFixed(2)}% (Baseline: ${comparison.baseline.version} 기준)`);
1388
+ if (isCritical) {
1389
+ hasCritical = true;
1390
+ }
1391
+ else {
1392
+ hasWarning = true;
1393
+ }
1394
+ }
1395
+ const recall5Change = comparison.quality.recallChange[5] || 0;
1396
+ if (recall5Change < -recall5Threshold) {
1397
+ const isCritical = recall5Change < -criticalThreshold;
1398
+ messages.push(`Recall@5 저하: ${(recall5Change * 100).toFixed(2)}% (Baseline: ${comparison.baseline.version} 기준)`);
1399
+ if (isCritical) {
1400
+ hasCritical = true;
1401
+ }
1402
+ else {
1403
+ hasWarning = true;
1404
+ }
1405
+ }
1406
+ // 극단적 시나리오 검증 저하 감지
1407
+ if (comparison.extremeScenarios.lowVectorHighConsolidationChange < 0) {
1408
+ messages.push(`저벡터 유사도 + 고 consolidation 점수 검증 실패 (Baseline: ${comparison.baseline.version} 기준)`);
1409
+ hasWarning = true;
1410
+ recommendations.push('극단적 시나리오 검증이 실패했습니다. w2 상한 설정을 확인하세요.');
1411
+ }
1412
+ if (comparison.extremeScenarios.highVectorLowConsolidationChange < 0) {
1413
+ messages.push(`고벡터 유사도 + 저 consolidation 점수 검증 실패 (Baseline: ${comparison.baseline.version} 기준)`);
1414
+ hasWarning = true;
1415
+ recommendations.push('극단적 시나리오 검증이 실패했습니다. 벡터 유사도 가중치를 확인하세요.');
1416
+ }
1417
+ // 심각도 결정
1418
+ if (hasCritical) {
1419
+ severity = 'critical';
1420
+ }
1421
+ else if (hasWarning || comparison.hasDegradation) {
1422
+ severity = 'warning';
1423
+ }
1424
+ // 감지 여부 결정
1425
+ const detected = comparison.hasDegradation || messages.length > 0;
1426
+ return {
1427
+ detected,
1428
+ severity,
1429
+ messages,
1430
+ comparison,
1431
+ recommendations: recommendations.length > 0 ? recommendations : []
1432
+ };
1433
+ }
1434
+ /**
1435
+ * 품질 저하 경고 메시지 출력
1436
+ * 품질 저하 감지 결과를 사용자 친화적인 형식으로 출력합니다.
1437
+ *
1438
+ * @param detection 품질 저하 감지 결과
1439
+ * @param options 출력 옵션
1440
+ *
1441
+ * @example
1442
+ * ```typescript
1443
+ * const detection = detectQualityDegradation(comparison);
1444
+ * printQualityAlert(detection, { output: 'console', useColors: true });
1445
+ * ```
1446
+ */
1447
+ export function printQualityAlert(detection, options = {}) {
1448
+ const { output = 'console', filePath, useColors = true, includeDetails = true, includeBaselineInfo = true } = options;
1449
+ // 감지되지 않았으면 출력하지 않음
1450
+ if (!detection.detected) {
1451
+ return;
1452
+ }
1453
+ const lines = [];
1454
+ // 헤더
1455
+ const severityLabel = detection.severity === 'critical'
1456
+ ? '🚨 CRITICAL'
1457
+ : detection.severity === 'warning'
1458
+ ? '⚠️ WARNING'
1459
+ : 'ℹ️ INFO';
1460
+ lines.push('='.repeat(80));
1461
+ lines.push(`${severityLabel} 품질 저하 감지`);
1462
+ lines.push('='.repeat(80));
1463
+ lines.push('');
1464
+ // Baseline 정보
1465
+ if (includeBaselineInfo) {
1466
+ lines.push(`Baseline 버전: ${detection.comparison.baseline.version}`);
1467
+ lines.push(`Baseline 생성 시간: ${detection.comparison.baseline.timestamp}`);
1468
+ lines.push('');
1469
+ }
1470
+ // 품질 저하 메시지
1471
+ if (detection.messages.length > 0) {
1472
+ lines.push('감지된 품질 저하:');
1473
+ lines.push('');
1474
+ detection.messages.forEach((msg, index) => {
1475
+ lines.push(` ${index + 1}. ${msg}`);
1476
+ });
1477
+ lines.push('');
1478
+ }
1479
+ // 권장 조치 사항
1480
+ if (detection.recommendations.length > 0) {
1481
+ lines.push('권장 조치 사항:');
1482
+ lines.push('');
1483
+ detection.recommendations.forEach((rec, index) => {
1484
+ lines.push(` ${index + 1}. ${rec}`);
1485
+ });
1486
+ lines.push('');
1487
+ }
1488
+ // 상세 정보
1489
+ if (includeDetails) {
1490
+ lines.push('상세 정보:');
1491
+ lines.push('');
1492
+ // 순서 보존 지표
1493
+ const orderPres = detection.comparison.orderPreservation;
1494
+ lines.push('순서 보존 지표:');
1495
+ lines.push(` - Kendall's Tau 변화: ${orderPres.kendallTauChange >= 0 ? '+' : ''}${orderPres.kendallTauChange.toFixed(3)}`);
1496
+ lines.push(` - Top10 유지율 변화: ${orderPres.top10RetentionChange >= 0 ? '+' : ''}${(orderPres.top10RetentionChange * 100).toFixed(2)}%`);
1497
+ lines.push(` - Top5 유지율 변화: ${orderPres.top5RetentionChange >= 0 ? '+' : ''}${(orderPres.top5RetentionChange * 100).toFixed(2)}%`);
1498
+ lines.push('');
1499
+ // 품질 지표
1500
+ const quality = detection.comparison.quality;
1501
+ lines.push('품질 지표 변화:');
1502
+ const kValues = Object.keys(quality.ndcgChange || {}).map(Number).sort((a, b) => a - b);
1503
+ if (kValues.length > 0) {
1504
+ lines.push(' NDCG@K:');
1505
+ kValues.forEach(k => {
1506
+ const change = quality.ndcgChange[k] || 0;
1507
+ lines.push(` - NDCG@${k}: ${change >= 0 ? '+' : ''}${(change * 100).toFixed(2)}%`);
1508
+ });
1509
+ lines.push(' Precision@K:');
1510
+ kValues.forEach(k => {
1511
+ const change = quality.precisionChange[k] || 0;
1512
+ lines.push(` - Precision@${k}: ${change >= 0 ? '+' : ''}${(change * 100).toFixed(2)}%`);
1513
+ });
1514
+ lines.push(' Recall@K:');
1515
+ kValues.forEach(k => {
1516
+ const change = quality.recallChange[k] || 0;
1517
+ lines.push(` - Recall@${k}: ${change >= 0 ? '+' : ''}${(change * 100).toFixed(2)}%`);
1518
+ });
1519
+ }
1520
+ lines.push('');
1521
+ }
1522
+ lines.push('='.repeat(80));
1523
+ lines.push('');
1524
+ const alertText = lines.join('\n');
1525
+ // 콘솔 출력
1526
+ if (output === 'console' || output === 'both') {
1527
+ if (detection.severity === 'critical') {
1528
+ // Critical은 stderr로 출력
1529
+ console.error(alertText);
1530
+ }
1531
+ else if (detection.severity === 'warning') {
1532
+ // Warning은 console.warn으로 출력
1533
+ console.warn(alertText);
1534
+ }
1535
+ else {
1536
+ // Info는 console.log로 출력
1537
+ console.log(alertText);
1538
+ }
1539
+ }
1540
+ // 파일 출력
1541
+ if ((output === 'file' || output === 'both') && filePath) {
1542
+ const dir = dirname(filePath);
1543
+ if (!existsSync(dir)) {
1544
+ mkdirSync(dir, { recursive: true });
1545
+ }
1546
+ try {
1547
+ writeFileSync(filePath, alertText, 'utf-8');
1548
+ }
1549
+ catch (error) {
1550
+ throw new Error(`경고 메시지 파일 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
1551
+ }
1552
+ }
1553
+ }
1554
+ /**
1555
+ * 품질 저하 감지 및 경고 출력 (통합 함수)
1556
+ * Baseline 비교 결과를 분석하여 품질 저하를 감지하고 경고 메시지를 출력합니다.
1557
+ *
1558
+ * @param comparison Baseline 비교 결과
1559
+ * @param detectionOptions 감지 옵션
1560
+ * @param alertOptions 경고 출력 옵션
1561
+ * @returns 품질 저하 감지 결과
1562
+ *
1563
+ * @example
1564
+ * ```typescript
1565
+ * const comparison = compareWithBaseline(baseline, currentMetrics);
1566
+ * detectAndAlertQualityDegradation(comparison, {}, { output: 'console' });
1567
+ * ```
1568
+ */
1569
+ export function detectAndAlertQualityDegradation(comparison, detectionOptions = {}, alertOptions = {}) {
1570
+ // 품질 저하 감지
1571
+ const detection = detectQualityDegradation(comparison, detectionOptions);
1572
+ // 경고 메시지 출력
1573
+ printQualityAlert(detection, alertOptions);
1574
+ return detection;
1575
+ }
1576
+ /**
1577
+ * 시드 기반 랜덤 생성기 (Ground Truth 생성용)
1578
+ * 재현 가능한 랜덤 값 생성
1579
+ */
1580
+ class GroundTruthSeededRandom {
1581
+ seed;
1582
+ constructor(seed) {
1583
+ this.seed = seed;
1584
+ }
1585
+ /**
1586
+ * 0과 1 사이의 랜덤 값 생성
1587
+ */
1588
+ random() {
1589
+ // LCG: (a * seed + c) mod m
1590
+ // a = 1664525, c = 1013904223, m = 2^32
1591
+ this.seed = (this.seed * 1664525 + 1013904223) % 0x100000000;
1592
+ return this.seed / 0x100000000;
1593
+ }
1594
+ /**
1595
+ * min과 max 사이의 정수 랜덤 값 생성
1596
+ */
1597
+ randomInt(min, max) {
1598
+ return Math.floor(this.random() * (max - min + 1)) + min;
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Ground Truth 자동 생성
1603
+ * 시드 기반으로 재현 가능한 Ground Truth 생성
1604
+ *
1605
+ * @param memoryIds 메모리 ID 배열
1606
+ * @param options 생성 옵션
1607
+ * @returns Ground Truth 배열
1608
+ *
1609
+ * @example
1610
+ * ```typescript
1611
+ * // 기본 옵션으로 생성
1612
+ * const groundTruths = generateGroundTruth(memoryIds);
1613
+ *
1614
+ * // 시드와 쿼리 지정
1615
+ * const groundTruths = generateGroundTruth(memoryIds, {
1616
+ * seed: 12345,
1617
+ * queries: ['React', 'TypeScript'],
1618
+ * relevantCountPerQuery: 3
1619
+ * });
1620
+ * ```
1621
+ */
1622
+ export function generateGroundTruth(memoryIds, options = {}) {
1623
+ const { seed = 12345, queries = ['React', 'TypeScript', 'database', 'MCP', 'optimization'], relevantCountPerQuery = 5, selectionStrategy = 'random' } = options;
1624
+ const rng = new GroundTruthSeededRandom(seed);
1625
+ const groundTruths = [];
1626
+ queries.forEach((query, queryIndex) => {
1627
+ let relevantIds;
1628
+ switch (selectionStrategy) {
1629
+ case 'first':
1630
+ // 처음 N개 선택
1631
+ relevantIds = memoryIds.slice(0, relevantCountPerQuery);
1632
+ break;
1633
+ case 'pattern':
1634
+ // 패턴 기반 선택 (쿼리별로 다른 패턴)
1635
+ relevantIds = memoryIds.filter((_, i) => i % (queries.length + 1) === queryIndex).slice(0, relevantCountPerQuery);
1636
+ break;
1637
+ case 'random':
1638
+ default:
1639
+ // 랜덤 선택 (시드 기반)
1640
+ const shuffled = [...memoryIds];
1641
+ // Fisher-Yates 셔플 (시드 기반)
1642
+ for (let i = shuffled.length - 1; i > 0; i--) {
1643
+ const j = rng.randomInt(0, i);
1644
+ const temp = shuffled[i];
1645
+ if (temp !== undefined && shuffled[j] !== undefined) {
1646
+ shuffled[i] = shuffled[j];
1647
+ shuffled[j] = temp;
1648
+ }
1649
+ }
1650
+ relevantIds = shuffled.slice(0, relevantCountPerQuery);
1651
+ break;
1652
+ }
1653
+ groundTruths.push({
1654
+ queryId: query,
1655
+ relevantIds
1656
+ });
1657
+ });
1658
+ return groundTruths;
1659
+ }
1660
+ /**
1661
+ * Ground Truth 저장
1662
+ * JSON 파일로 Ground Truth를 저장합니다.
1663
+ *
1664
+ * @param groundTruths 저장할 Ground Truth 배열
1665
+ * @param filePath 저장할 파일 경로 (기본값: `data/vector-search-quality-ground-truth.json`)
1666
+ *
1667
+ * @example
1668
+ * ```typescript
1669
+ * const groundTruths = generateGroundTruth(memoryIds);
1670
+ * saveGroundTruth(groundTruths);
1671
+ * ```
1672
+ */
1673
+ export function saveGroundTruth(groundTruths, filePath) {
1674
+ const defaultPath = join(__dirname, '../../../data/vector-search-quality-ground-truth.json');
1675
+ const targetPath = filePath || defaultPath;
1676
+ const dir = dirname(targetPath);
1677
+ // 디렉토리가 없으면 생성
1678
+ if (!existsSync(dir)) {
1679
+ mkdirSync(dir, { recursive: true });
1680
+ }
1681
+ try {
1682
+ const jsonContent = JSON.stringify(groundTruths, null, 2);
1683
+ writeFileSync(targetPath, jsonContent, 'utf-8');
1684
+ }
1685
+ catch (error) {
1686
+ throw new Error(`Ground Truth 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
1687
+ }
1688
+ }
1689
+ /**
1690
+ * Ground Truth 로드
1691
+ * JSON 파일에서 Ground Truth를 로드합니다.
1692
+ *
1693
+ * @param filePath 로드할 파일 경로 (기본값: `data/vector-search-quality-ground-truth.json`)
1694
+ * @returns 로드된 Ground Truth 배열 또는 null (파일이 없거나 로드 실패 시)
1695
+ *
1696
+ * @example
1697
+ * ```typescript
1698
+ * const groundTruths = loadGroundTruth();
1699
+ * if (groundTruths) {
1700
+ * console.log(`로드된 Ground Truth 수: ${groundTruths.length}`);
1701
+ * } else {
1702
+ * console.log('Ground Truth 파일이 없습니다. 새로 생성합니다.');
1703
+ * const newGroundTruths = generateGroundTruth(memoryIds);
1704
+ * saveGroundTruth(newGroundTruths);
1705
+ * }
1706
+ * ```
1707
+ */
1708
+ export function loadGroundTruth(filePath) {
1709
+ const defaultPath = join(__dirname, '../../../data/vector-search-quality-ground-truth.json');
1710
+ const targetPath = filePath || defaultPath;
1711
+ // 파일 존재 여부 확인
1712
+ if (!existsSync(targetPath)) {
1713
+ return null;
1714
+ }
1715
+ try {
1716
+ // 파일 읽기
1717
+ const content = readFileSync(targetPath, 'utf-8');
1718
+ // JSON 파싱
1719
+ const groundTruths = JSON.parse(content);
1720
+ // 기본 검증 (배열이고 각 항목이 올바른 형식인지 확인)
1721
+ if (!Array.isArray(groundTruths)) {
1722
+ throw new Error('Ground Truth는 배열이어야 합니다.');
1723
+ }
1724
+ for (const gt of groundTruths) {
1725
+ if (!gt.queryId || !Array.isArray(gt.relevantIds)) {
1726
+ throw new Error('Ground Truth 형식이 올바르지 않습니다.');
1727
+ }
1728
+ }
1729
+ return groundTruths;
1730
+ }
1731
+ catch (error) {
1732
+ // 로드 실패 시 null 반환 (에러 로깅은 호출자가 처리)
1733
+ return null;
1734
+ }
1735
+ }
1736
+ /**
1737
+ * Ground Truth 생성 또는 로드
1738
+ * 파일이 있으면 로드하고, 없으면 자동 생성하여 저장합니다.
1739
+ *
1740
+ * @param memoryIds 메모리 ID 배열
1741
+ * @param options 생성 옵션 (파일이 없을 때만 사용)
1742
+ * @param filePath Ground Truth 파일 경로 (기본값: `data/vector-search-quality-ground-truth.json`)
1743
+ * @returns Ground Truth 배열
1744
+ *
1745
+ * @example
1746
+ * ```typescript
1747
+ * // 파일이 있으면 로드, 없으면 생성
1748
+ * const groundTruths = generateOrLoadGroundTruth(memoryIds, {
1749
+ * seed: 12345,
1750
+ * queries: ['React', 'TypeScript']
1751
+ * });
1752
+ * ```
1753
+ */
1754
+ export function generateOrLoadGroundTruth(memoryIds, options = {}, filePath) {
1755
+ // 먼저 파일에서 로드 시도
1756
+ const loaded = loadGroundTruth(filePath);
1757
+ if (loaded) {
1758
+ return loaded;
1759
+ }
1760
+ // 파일이 없으면 생성
1761
+ const generated = generateGroundTruth(memoryIds, options);
1762
+ // 생성한 Ground Truth 저장
1763
+ saveGroundTruth(generated, filePath);
1764
+ return generated;
1765
+ }
1766
+ /**
1767
+ * 순서 보존 리포트 저장
1768
+ * 순서 보존 리포트를 JSON 또는 Markdown 형식으로 파일에 저장합니다.
1769
+ *
1770
+ * @param report 저장할 순서 보존 리포트
1771
+ * @param options 저장 옵션
1772
+ *
1773
+ * @example
1774
+ * ```typescript
1775
+ * const report = generateOrderPreservationReport(pair);
1776
+ * saveOrderPreservationReport(report, { format: 'markdown' });
1777
+ * ```
1778
+ */
1779
+ export function saveOrderPreservationReport(report, options = {}) {
1780
+ const { format = 'both', includeTimestamp = true } = options;
1781
+ const timestamp = includeTimestamp
1782
+ ? `_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`
1783
+ : '';
1784
+ const defaultJsonPath = join(__dirname, `../../../data/order-preservation-report${timestamp}.json`);
1785
+ const defaultMarkdownPath = join(__dirname, `../../../data/order-preservation-report${timestamp}.md`);
1786
+ const jsonPath = options.filePath && format === 'json'
1787
+ ? options.filePath
1788
+ : format === 'both'
1789
+ ? defaultJsonPath.replace('.md', '.json')
1790
+ : format === 'json'
1791
+ ? defaultJsonPath
1792
+ : undefined;
1793
+ const markdownPath = options.filePath && format === 'markdown'
1794
+ ? options.filePath
1795
+ : format === 'both'
1796
+ ? defaultMarkdownPath
1797
+ : format === 'markdown'
1798
+ ? defaultMarkdownPath
1799
+ : undefined;
1800
+ // 디렉토리 생성
1801
+ if (jsonPath) {
1802
+ const dir = dirname(jsonPath);
1803
+ if (!existsSync(dir)) {
1804
+ mkdirSync(dir, { recursive: true });
1805
+ }
1806
+ }
1807
+ if (markdownPath) {
1808
+ const dir = dirname(markdownPath);
1809
+ if (!existsSync(dir)) {
1810
+ mkdirSync(dir, { recursive: true });
1811
+ }
1812
+ }
1813
+ try {
1814
+ // JSON 형식 저장
1815
+ if (jsonPath) {
1816
+ const jsonContent = JSON.stringify(report, null, 2);
1817
+ writeFileSync(jsonPath, jsonContent, 'utf-8');
1818
+ }
1819
+ // Markdown 형식 저장
1820
+ if (markdownPath) {
1821
+ const markdownLines = [];
1822
+ markdownLines.push('# 순서 보존 검증 리포트');
1823
+ markdownLines.push('');
1824
+ markdownLines.push(`**생성 시간**: ${report.timestamp}`);
1825
+ markdownLines.push(`**검증 통과**: ${report.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
1826
+ markdownLines.push('');
1827
+ markdownLines.push('## 순서 보존 지표');
1828
+ markdownLines.push('');
1829
+ markdownLines.push('| 지표 | 값 |');
1830
+ markdownLines.push('|------|-----|');
1831
+ markdownLines.push(`| Kendall's Tau | ${report.metrics.kendallTau.toFixed(3)} |`);
1832
+ markdownLines.push(`| Top10 유지율 | ${(report.metrics.top10Retention * 100).toFixed(2)}% |`);
1833
+ markdownLines.push(`| Top5 유지율 | ${(report.metrics.top5Retention * 100).toFixed(2)}% |`);
1834
+ if (report.metrics.spearmanRho !== undefined) {
1835
+ markdownLines.push(`| Spearman's Rho | ${report.metrics.spearmanRho.toFixed(3)} |`);
1836
+ }
1837
+ markdownLines.push('');
1838
+ // 검증 결과
1839
+ markdownLines.push('## 검증 결과');
1840
+ markdownLines.push('');
1841
+ markdownLines.push('| 항목 | 임계값 | 실제 값 | 상태 |');
1842
+ markdownLines.push('|------|--------|---------|------|');
1843
+ const kendallTauStatus = report.metrics.kendallTau >= (report.thresholds?.kendallTauThreshold || 0.7)
1844
+ ? '[PASS] 통과'
1845
+ : '[FAIL] 실패';
1846
+ const top10Status = report.metrics.top10Retention >= (report.thresholds?.top10RetentionThreshold || 0.8)
1847
+ ? '[PASS] 통과'
1848
+ : '[FAIL] 실패';
1849
+ const top5Status = report.metrics.top5Retention >= (report.thresholds?.top5RetentionThreshold || 0.9)
1850
+ ? '[PASS] 통과'
1851
+ : '[FAIL] 실패';
1852
+ markdownLines.push(`| Kendall's Tau | ≥ ${(report.thresholds?.kendallTauThreshold || 0.7).toFixed(1)} | ${report.metrics.kendallTau.toFixed(3)} | ${kendallTauStatus} |`);
1853
+ markdownLines.push(`| Top10 유지율 | ≥ ${((report.thresholds?.top10RetentionThreshold || 0.8) * 100).toFixed(0)}% | ${(report.metrics.top10Retention * 100).toFixed(2)}% | ${top10Status} |`);
1854
+ markdownLines.push(`| Top5 유지율 | ≥ ${((report.thresholds?.top5RetentionThreshold || 0.9) * 100).toFixed(0)}% | ${(report.metrics.top5Retention * 100).toFixed(2)}% | ${top5Status} |`);
1855
+ markdownLines.push('');
1856
+ // 실패 사유
1857
+ if (report.failureReasons && report.failureReasons.length > 0) {
1858
+ markdownLines.push('### 실패 사유');
1859
+ markdownLines.push('');
1860
+ report.failureReasons.forEach(reason => {
1861
+ markdownLines.push(`- [FAIL] ${reason}`);
1862
+ });
1863
+ markdownLines.push('');
1864
+ }
1865
+ const markdownContent = markdownLines.join('\n');
1866
+ writeFileSync(markdownPath, markdownContent, 'utf-8');
1867
+ }
1868
+ }
1869
+ catch (error) {
1870
+ throw new Error(`순서 보존 리포트 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
1871
+ }
1872
+ }
1873
+ /**
1874
+ * 품질 비교 리포트 저장
1875
+ * 품질 비교 리포트를 JSON 또는 Markdown 형식으로 파일에 저장합니다.
1876
+ *
1877
+ * @param report 저장할 품질 비교 리포트
1878
+ * @param options 저장 옵션
1879
+ *
1880
+ * @example
1881
+ * ```typescript
1882
+ * const report = generateQualityComparisonReport(comparison, groundTruth);
1883
+ * saveQualityComparisonReport(report, { format: 'markdown' });
1884
+ * ```
1885
+ */
1886
+ export function saveQualityComparisonReport(report, options = {}) {
1887
+ const { format = 'both', includeTimestamp = true } = options;
1888
+ const timestamp = includeTimestamp
1889
+ ? `_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`
1890
+ : '';
1891
+ const defaultJsonPath = join(__dirname, `../../../data/quality-comparison-report${timestamp}.json`);
1892
+ const defaultMarkdownPath = join(__dirname, `../../../data/quality-comparison-report${timestamp}.md`);
1893
+ const jsonPath = options.filePath && format === 'json'
1894
+ ? options.filePath
1895
+ : format === 'both'
1896
+ ? defaultJsonPath.replace('.md', '.json')
1897
+ : format === 'json'
1898
+ ? defaultJsonPath
1899
+ : undefined;
1900
+ const markdownPath = options.filePath && format === 'markdown'
1901
+ ? options.filePath
1902
+ : format === 'both'
1903
+ ? defaultMarkdownPath
1904
+ : format === 'markdown'
1905
+ ? defaultMarkdownPath
1906
+ : undefined;
1907
+ // 디렉토리 생성
1908
+ if (jsonPath) {
1909
+ const dir = dirname(jsonPath);
1910
+ if (!existsSync(dir)) {
1911
+ mkdirSync(dir, { recursive: true });
1912
+ }
1913
+ }
1914
+ if (markdownPath) {
1915
+ const dir = dirname(markdownPath);
1916
+ if (!existsSync(dir)) {
1917
+ mkdirSync(dir, { recursive: true });
1918
+ }
1919
+ }
1920
+ try {
1921
+ // JSON 형식 저장
1922
+ if (jsonPath) {
1923
+ const jsonContent = JSON.stringify(report, null, 2);
1924
+ writeFileSync(jsonPath, jsonContent, 'utf-8');
1925
+ }
1926
+ // Markdown 형식 저장 (기존 visualizeQualityComparison 함수 활용)
1927
+ if (markdownPath) {
1928
+ const markdownContent = visualizeQualityComparison(report);
1929
+ writeFileSync(markdownPath, markdownContent, 'utf-8');
1930
+ }
1931
+ }
1932
+ catch (error) {
1933
+ throw new Error(`품질 비교 리포트 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
1934
+ }
1935
+ }
1936
+ /**
1937
+ * 극단적 시나리오 리포트 저장
1938
+ * 극단적 시나리오 리포트를 JSON 또는 Markdown 형식으로 파일에 저장합니다.
1939
+ *
1940
+ * @param report 저장할 극단적 시나리오 리포트
1941
+ * @param options 저장 옵션
1942
+ *
1943
+ * @example
1944
+ * ```typescript
1945
+ * const report = generateExtremeScenarioReport(lowVectorHigh, highVectorLow, w2Validation);
1946
+ * saveExtremeScenarioReport(report, { format: 'markdown' });
1947
+ * ```
1948
+ */
1949
+ export function saveExtremeScenarioReport(report, options = {}) {
1950
+ const { format = 'both', includeTimestamp = true } = options;
1951
+ const timestamp = includeTimestamp
1952
+ ? `_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`
1953
+ : '';
1954
+ const defaultJsonPath = join(__dirname, `../../../data/extreme-scenario-report${timestamp}.json`);
1955
+ const defaultMarkdownPath = join(__dirname, `../../../data/extreme-scenario-report${timestamp}.md`);
1956
+ const jsonPath = options.filePath && format === 'json'
1957
+ ? options.filePath
1958
+ : format === 'both'
1959
+ ? defaultJsonPath.replace('.md', '.json')
1960
+ : format === 'json'
1961
+ ? defaultJsonPath
1962
+ : undefined;
1963
+ const markdownPath = options.filePath && format === 'markdown'
1964
+ ? options.filePath
1965
+ : format === 'both'
1966
+ ? defaultMarkdownPath
1967
+ : format === 'markdown'
1968
+ ? defaultMarkdownPath
1969
+ : undefined;
1970
+ // 디렉토리 생성
1971
+ if (jsonPath) {
1972
+ const dir = dirname(jsonPath);
1973
+ if (!existsSync(dir)) {
1974
+ mkdirSync(dir, { recursive: true });
1975
+ }
1976
+ }
1977
+ if (markdownPath) {
1978
+ const dir = dirname(markdownPath);
1979
+ if (!existsSync(dir)) {
1980
+ mkdirSync(dir, { recursive: true });
1981
+ }
1982
+ }
1983
+ try {
1984
+ // JSON 형식 저장
1985
+ if (jsonPath) {
1986
+ const jsonContent = JSON.stringify(report, null, 2);
1987
+ writeFileSync(jsonPath, jsonContent, 'utf-8');
1988
+ }
1989
+ // Markdown 형식 저장
1990
+ if (markdownPath) {
1991
+ const markdownLines = [];
1992
+ markdownLines.push('# 극단적 시나리오 검증 리포트');
1993
+ markdownLines.push('');
1994
+ markdownLines.push(`**생성 시간**: ${report.timestamp}`);
1995
+ markdownLines.push(`**전체 검증 통과**: ${report.overallPassed ? '[PASS] 통과' : '[FAIL] 실패'}`);
1996
+ markdownLines.push('');
1997
+ markdownLines.push('## 검증 결과 요약');
1998
+ markdownLines.push('');
1999
+ markdownLines.push(`- **통과한 시나리오**: ${report.summary.passedCount} / ${report.summary.totalCount}`);
2000
+ markdownLines.push(`- **실패한 시나리오**: ${report.summary.failedScenarios.length}`);
2001
+ markdownLines.push('');
2002
+ if (report.summary.failedScenarios.length > 0) {
2003
+ markdownLines.push('### 실패한 시나리오');
2004
+ markdownLines.push('');
2005
+ report.summary.failedScenarios.forEach(scenario => {
2006
+ markdownLines.push(`- [FAIL] ${scenario}`);
2007
+ });
2008
+ markdownLines.push('');
2009
+ }
2010
+ // 저벡터 유사도 + 고 consolidation 점수 검증
2011
+ markdownLines.push('## 저벡터 유사도 + 고 consolidation 점수 검증');
2012
+ markdownLines.push('');
2013
+ markdownLines.push(`**검증 통과**: ${report.lowVectorHighConsolidation.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
2014
+ markdownLines.push('');
2015
+ markdownLines.push('| 지표 | 값 |');
2016
+ markdownLines.push('|------|-----|');
2017
+ markdownLines.push(`| 최종 점수 범위 | ${report.lowVectorHighConsolidation.finalScoreRange.min.toFixed(3)} ~ ${report.lowVectorHighConsolidation.finalScoreRange.max.toFixed(3)} |`);
2018
+ markdownLines.push(`| 최종 점수 평균 | ${report.lowVectorHighConsolidation.finalScoreRange.average.toFixed(3)} |`);
2019
+ markdownLines.push(`| 벡터 유사도 평균 | ${report.lowVectorHighConsolidation.vectorSimilarityStats.average.toFixed(3)} |`);
2020
+ markdownLines.push(`| Consolidation 점수 평균 | ${report.lowVectorHighConsolidation.consolidationScoreStats.average.toFixed(3)} |`);
2021
+ markdownLines.push('');
2022
+ if (report.lowVectorHighConsolidation.failureReasons && report.lowVectorHighConsolidation.failureReasons.length > 0) {
2023
+ markdownLines.push('### 실패 사유');
2024
+ markdownLines.push('');
2025
+ report.lowVectorHighConsolidation.failureReasons.forEach(reason => {
2026
+ markdownLines.push(`- [FAIL] ${reason}`);
2027
+ });
2028
+ markdownLines.push('');
2029
+ }
2030
+ // 고벡터 유사도 + 저 consolidation 점수 검증
2031
+ markdownLines.push('## 고벡터 유사도 + 저 consolidation 점수 검증');
2032
+ markdownLines.push('');
2033
+ markdownLines.push(`**검증 통과**: ${report.highVectorLowConsolidation.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
2034
+ markdownLines.push('');
2035
+ markdownLines.push('| 지표 | 값 |');
2036
+ markdownLines.push('|------|-----|');
2037
+ markdownLines.push(`| 최종 점수 범위 | ${report.highVectorLowConsolidation.finalScoreRange.min.toFixed(3)} ~ ${report.highVectorLowConsolidation.finalScoreRange.max.toFixed(3)} |`);
2038
+ markdownLines.push(`| 최종 점수 평균 | ${report.highVectorLowConsolidation.finalScoreRange.average.toFixed(3)} |`);
2039
+ markdownLines.push(`| 벡터 유사도 평균 | ${report.highVectorLowConsolidation.vectorSimilarityStats.average.toFixed(3)} |`);
2040
+ markdownLines.push(`| Consolidation 점수 평균 | ${report.highVectorLowConsolidation.consolidationScoreStats.average.toFixed(3)} |`);
2041
+ markdownLines.push('');
2042
+ if (report.highVectorLowConsolidation.failureReasons && report.highVectorLowConsolidation.failureReasons.length > 0) {
2043
+ markdownLines.push('### 실패 사유');
2044
+ markdownLines.push('');
2045
+ report.highVectorLowConsolidation.failureReasons.forEach(reason => {
2046
+ markdownLines.push(`- [FAIL] ${reason}`);
2047
+ });
2048
+ markdownLines.push('');
2049
+ }
2050
+ // w2 상한 검증
2051
+ markdownLines.push('## w2 상한 검증');
2052
+ markdownLines.push('');
2053
+ markdownLines.push(`**검증 통과**: ${report.w2UpperBound.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
2054
+ markdownLines.push('');
2055
+ if (report.w2UpperBound.w2_04 && report.w2UpperBound.w2_06) {
2056
+ markdownLines.push('| 지표 | w2=0.4 | w2=0.6 | 품질 저하 |');
2057
+ markdownLines.push('|------|--------|--------|----------|');
2058
+ const kValues = Object.keys(report.w2UpperBound.w2_04.ndcg || {}).map(Number);
2059
+ kValues.forEach(k => {
2060
+ const ndcg4 = report.w2UpperBound.w2_04.ndcg[k] || 0;
2061
+ const ndcg6 = report.w2UpperBound.w2_06.ndcg[k] || 0;
2062
+ const degradation = report.w2UpperBound.degradation?.ndcg?.[k] || 0;
2063
+ markdownLines.push(`| NDCG@${k} | ${ndcg4.toFixed(3)} | ${ndcg6.toFixed(3)} | ${(degradation * 100).toFixed(2)}% |`);
2064
+ });
2065
+ markdownLines.push('');
2066
+ }
2067
+ if (report.w2UpperBound.failureReasons && report.w2UpperBound.failureReasons.length > 0) {
2068
+ markdownLines.push('### 실패 사유');
2069
+ markdownLines.push('');
2070
+ report.w2UpperBound.failureReasons.forEach(reason => {
2071
+ markdownLines.push(`- [FAIL] ${reason}`);
2072
+ });
2073
+ markdownLines.push('');
2074
+ }
2075
+ const markdownContent = markdownLines.join('\n');
2076
+ writeFileSync(markdownPath, markdownContent, 'utf-8');
2077
+ }
2078
+ }
2079
+ catch (error) {
2080
+ throw new Error(`극단적 시나리오 리포트 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
2081
+ }
2082
+ }
2083
+ export function saveIntegratedReport(reports, options = {}) {
2084
+ const { format = 'both', includeTimestamp = true } = options;
2085
+ const timestamp = includeTimestamp
2086
+ ? `_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`
2087
+ : '';
2088
+ const defaultJsonPath = join(__dirname, `../../../data/vector-search-quality-report${timestamp}.json`);
2089
+ const defaultMarkdownPath = join(__dirname, `../../../data/vector-search-quality-report${timestamp}.md`);
2090
+ const jsonPath = options.filePath && format === 'json'
2091
+ ? options.filePath
2092
+ : format === 'both'
2093
+ ? defaultJsonPath.replace('.md', '.json')
2094
+ : format === 'json'
2095
+ ? defaultJsonPath
2096
+ : undefined;
2097
+ const markdownPath = options.filePath && format === 'markdown'
2098
+ ? options.filePath
2099
+ : format === 'both'
2100
+ ? defaultMarkdownPath
2101
+ : format === 'markdown'
2102
+ ? defaultMarkdownPath
2103
+ : undefined;
2104
+ // 디렉토리 생성
2105
+ if (jsonPath) {
2106
+ const dir = dirname(jsonPath);
2107
+ if (!existsSync(dir)) {
2108
+ mkdirSync(dir, { recursive: true });
2109
+ }
2110
+ }
2111
+ if (markdownPath) {
2112
+ const dir = dirname(markdownPath);
2113
+ if (!existsSync(dir)) {
2114
+ mkdirSync(dir, { recursive: true });
2115
+ }
2116
+ }
2117
+ try {
2118
+ // JSON 형식 저장
2119
+ if (jsonPath) {
2120
+ const jsonContent = JSON.stringify(reports, null, 2);
2121
+ writeFileSync(jsonPath, jsonContent, 'utf-8');
2122
+ }
2123
+ // Markdown 형식 저장
2124
+ if (markdownPath) {
2125
+ const markdownLines = [];
2126
+ markdownLines.push('# 벡터 검색 품질 검증 통합 리포트');
2127
+ markdownLines.push('');
2128
+ markdownLines.push(`**생성 시간**: ${new Date().toISOString()}`);
2129
+ markdownLines.push('');
2130
+ // 순서 보존 리포트
2131
+ if (reports.orderReport) {
2132
+ markdownLines.push('## 1. 순서 보존 검증');
2133
+ markdownLines.push('');
2134
+ markdownLines.push(`**검증 통과**: ${reports.orderReport.passed ? '[PASS] 통과' : '[FAIL] 실패'}`);
2135
+ markdownLines.push('');
2136
+ markdownLines.push('| 지표 | 값 |');
2137
+ markdownLines.push('|------|-----|');
2138
+ markdownLines.push(`| Kendall's Tau | ${reports.orderReport.metrics.kendallTau.toFixed(3)} |`);
2139
+ markdownLines.push(`| Top10 유지율 | ${(reports.orderReport.metrics.top10Retention * 100).toFixed(2)}% |`);
2140
+ markdownLines.push(`| Top5 유지율 | ${(reports.orderReport.metrics.top5Retention * 100).toFixed(2)}% |`);
2141
+ markdownLines.push('');
2142
+ }
2143
+ // 품질 비교 리포트
2144
+ if (reports.qualityReport) {
2145
+ markdownLines.push('## 2. 품질 지표 비교');
2146
+ markdownLines.push('');
2147
+ const qualityMarkdown = visualizeQualityComparison(reports.qualityReport);
2148
+ // 헤더 제거하고 내용만 추가
2149
+ const qualityContent = qualityMarkdown.split('\n').slice(1).join('\n');
2150
+ markdownLines.push(qualityContent);
2151
+ markdownLines.push('');
2152
+ }
2153
+ // 극단적 시나리오 리포트
2154
+ if (reports.extremeReport) {
2155
+ markdownLines.push('## 3. 극단적 시나리오 검증');
2156
+ markdownLines.push('');
2157
+ markdownLines.push(`**전체 검증 통과**: ${reports.extremeReport.overallPassed ? '[PASS] 통과' : '[FAIL] 실패'}`);
2158
+ markdownLines.push('');
2159
+ markdownLines.push(`- **통과한 시나리오**: ${reports.extremeReport.summary.passedCount} / ${reports.extremeReport.summary.totalCount}`);
2160
+ if (reports.extremeReport.summary.failedScenarios.length > 0) {
2161
+ markdownLines.push(`- **실패한 시나리오**: ${reports.extremeReport.summary.failedScenarios.join(', ')}`);
2162
+ }
2163
+ markdownLines.push('');
2164
+ }
2165
+ // Baseline 비교 결과
2166
+ if (reports.baselineComparison) {
2167
+ markdownLines.push('## 4. Baseline 비교');
2168
+ markdownLines.push('');
2169
+ markdownLines.push(`**Baseline 버전**: ${reports.baselineComparison.baseline.version}`);
2170
+ markdownLines.push(`**Baseline 생성 시간**: ${reports.baselineComparison.baseline.timestamp}`);
2171
+ markdownLines.push(`**품질 저하 감지**: ${reports.baselineComparison.hasDegradation ? '[WARNING] 감지됨' : '[PASS] 없음'}`);
2172
+ markdownLines.push('');
2173
+ if (reports.baselineComparison.degradationDetails.length > 0) {
2174
+ markdownLines.push('### 저하 상세');
2175
+ markdownLines.push('');
2176
+ reports.baselineComparison.degradationDetails.forEach(detail => {
2177
+ markdownLines.push(`- [WARNING] ${detail}`);
2178
+ });
2179
+ markdownLines.push('');
2180
+ }
2181
+ }
2182
+ // 품질 저하 감지 결과
2183
+ if (reports.qualityDegradation) {
2184
+ markdownLines.push('## 5. 품질 저하 감지');
2185
+ markdownLines.push('');
2186
+ markdownLines.push(`**감지 여부**: ${reports.qualityDegradation.detected ? '[WARNING] 감지됨' : '[PASS] 없음'}`);
2187
+ markdownLines.push(`**심각도**: ${reports.qualityDegradation.severity}`);
2188
+ markdownLines.push('');
2189
+ if (reports.qualityDegradation.messages.length > 0) {
2190
+ markdownLines.push('### 경고 메시지');
2191
+ markdownLines.push('');
2192
+ reports.qualityDegradation.messages.forEach(message => {
2193
+ markdownLines.push(`- ${message}`);
2194
+ });
2195
+ markdownLines.push('');
2196
+ }
2197
+ if (reports.qualityDegradation.recommendations.length > 0) {
2198
+ markdownLines.push('### 권장사항');
2199
+ markdownLines.push('');
2200
+ reports.qualityDegradation.recommendations.forEach(recommendation => {
2201
+ markdownLines.push(`- ${recommendation}`);
2202
+ });
2203
+ markdownLines.push('');
2204
+ }
2205
+ }
2206
+ const markdownContent = markdownLines.join('\n');
2207
+ writeFileSync(markdownPath, markdownContent, 'utf-8');
2208
+ }
2209
+ }
2210
+ catch (error) {
2211
+ throw new Error(`통합 리포트 저장 실패: ${error instanceof Error ? error.message : String(error)}`);
2212
+ }
2213
+ }
2214
+ //# sourceMappingURL=vector-search-quality-metrics.js.map