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.
- package/dist/domains/relation/tools/visualize-relations-tool.d.ts +2 -2
- package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts +60 -0
- package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts.map +1 -0
- package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js +276 -0
- package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js.map +1 -0
- package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.sql +128 -0
- package/dist/infrastructure/scheduler/batch-scheduler.d.ts +17 -0
- package/dist/infrastructure/scheduler/batch-scheduler.d.ts.map +1 -1
- package/dist/infrastructure/scheduler/batch-scheduler.js +124 -0
- package/dist/infrastructure/scheduler/batch-scheduler.js.map +1 -1
- package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts +108 -0
- package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts.map +1 -0
- package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js +184 -0
- package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js.map +1 -0
- package/dist/server/http-server.d.ts.map +1 -1
- package/dist/server/http-server.js +3 -0
- package/dist/server/http-server.js.map +1 -1
- package/dist/server/routes/quality.routes.d.ts +14 -0
- package/dist/server/routes/quality.routes.d.ts.map +1 -0
- package/dist/server/routes/quality.routes.js +460 -0
- package/dist/server/routes/quality.routes.js.map +1 -0
- package/dist/services/quality-assurance/quality-assurance-service.d.ts +207 -0
- package/dist/services/quality-assurance/quality-assurance-service.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-assurance-service.js +247 -0
- package/dist/services/quality-assurance/quality-assurance-service.js.map +1 -0
- package/dist/services/quality-assurance/quality-evaluator.d.ts +163 -0
- package/dist/services/quality-assurance/quality-evaluator.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-evaluator.js +256 -0
- package/dist/services/quality-assurance/quality-evaluator.js.map +1 -0
- package/dist/services/quality-assurance/quality-metrics-collector.d.ts +219 -0
- package/dist/services/quality-assurance/quality-metrics-collector.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-metrics-collector.js +725 -0
- package/dist/services/quality-assurance/quality-metrics-collector.js.map +1 -0
- package/dist/services/quality-assurance/quality-recorder.d.ts +108 -0
- package/dist/services/quality-assurance/quality-recorder.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-recorder.js +281 -0
- package/dist/services/quality-assurance/quality-recorder.js.map +1 -0
- package/dist/services/quality-assurance/quality-reporter.d.ts +189 -0
- package/dist/services/quality-assurance/quality-reporter.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-reporter.js +558 -0
- package/dist/services/quality-assurance/quality-reporter.js.map +1 -0
- package/dist/services/quality-assurance/quality-threshold-manager.d.ts +102 -0
- package/dist/services/quality-assurance/quality-threshold-manager.d.ts.map +1 -0
- package/dist/services/quality-assurance/quality-threshold-manager.js +252 -0
- package/dist/services/quality-assurance/quality-threshold-manager.js.map +1 -0
- package/dist/test/helpers/search-quality-metrics.d.ts +96 -0
- package/dist/test/helpers/search-quality-metrics.d.ts.map +1 -0
- package/dist/test/helpers/search-quality-metrics.js +185 -0
- package/dist/test/helpers/search-quality-metrics.js.map +1 -0
- package/dist/test/helpers/vector-search-quality-metrics.d.ts +1287 -0
- package/dist/test/helpers/vector-search-quality-metrics.d.ts.map +1 -0
- package/dist/test/helpers/vector-search-quality-metrics.js +2214 -0
- package/dist/test/helpers/vector-search-quality-metrics.js.map +1 -0
- package/package.json +4 -1
- package/scripts/quality-report.ts +166 -0
- 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
|