doc2vec 2.16.0 → 2.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/database.js +114 -8
- package/dist/doc2vec.js +54 -3
- package/dist/eval.js +375 -0
- package/dist/ui/assets/index-ByuMPqOM.js +132 -0
- package/dist/ui/index.html +1 -1
- package/package.json +10 -5
- package/dist/ui/assets/index-C0Yy-BZB.js +0 -126
package/dist/eval.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Retrieval evaluation: runs a set of questions with known relevant URLs
|
|
3
|
+
// against a sqlite-vec database and reports recall@k and MRR. It measures
|
|
4
|
+
// the same thing the MCP server serves (nearest chunks by distance), so a
|
|
5
|
+
// change to the embedding model, chunking or ranking can be judged by
|
|
6
|
+
// numbers instead of by eyeballing a few queries.
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.DEFAULT_K_VALUES = exports.DEFAULT_RERANK_CANDIDATES = exports.DEFAULT_RERANK_MODEL = exports.RRF_K = void 0;
|
|
42
|
+
exports.createDeepInfraReranker = createDeepInfraReranker;
|
|
43
|
+
exports.normalizeUrl = normalizeUrl;
|
|
44
|
+
exports.firstRelevantRank = firstRelevantRank;
|
|
45
|
+
exports.computeMetrics = computeMetrics;
|
|
46
|
+
exports.loadEvalCases = loadEvalCases;
|
|
47
|
+
exports.runEval = runEval;
|
|
48
|
+
exports.onePerPage = onePerPage;
|
|
49
|
+
exports.fuseRrf = fuseRrf;
|
|
50
|
+
exports.toFtsQuery = toFtsQuery;
|
|
51
|
+
exports.createSqliteRetriever = createSqliteRetriever;
|
|
52
|
+
exports.createEmbedder = createEmbedder;
|
|
53
|
+
exports.formatReport = formatReport;
|
|
54
|
+
exports.runEvalCommand = runEvalCommand;
|
|
55
|
+
const fs = __importStar(require("fs"));
|
|
56
|
+
const path = __importStar(require("path"));
|
|
57
|
+
const yaml = __importStar(require("js-yaml"));
|
|
58
|
+
const openai_1 = require("openai");
|
|
59
|
+
exports.RRF_K = 60;
|
|
60
|
+
exports.DEFAULT_RERANK_MODEL = 'nvidia/Nemotron-3.5-Rerank-8B-BF16';
|
|
61
|
+
exports.DEFAULT_RERANK_CANDIDATES = 40;
|
|
62
|
+
// Same request the MCP server's reranker sends (mcp/src/server.ts)
|
|
63
|
+
function createDeepInfraReranker(apiKey, model = exports.DEFAULT_RERANK_MODEL) {
|
|
64
|
+
return async (query, documents) => {
|
|
65
|
+
const response = await fetch(`https://api.deepinfra.com/v1/inference/${model}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
68
|
+
body: JSON.stringify({ queries: [query], documents }),
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(`reranker returned HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
72
|
+
}
|
|
73
|
+
const body = await response.json();
|
|
74
|
+
if (!Array.isArray(body.scores) || body.scores.length !== documents.length) {
|
|
75
|
+
throw new Error('reranker did not return one score per document');
|
|
76
|
+
}
|
|
77
|
+
return body.scores.map(Number);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
exports.DEFAULT_K_VALUES = [1, 4, 10];
|
|
81
|
+
// Treat https://x/a/, https://x/a and https://x/a#section as the same page:
|
|
82
|
+
// eval sets are written by hand and the stored URLs vary on these points.
|
|
83
|
+
function normalizeUrl(url) {
|
|
84
|
+
let normalized = url.trim();
|
|
85
|
+
const hashIndex = normalized.indexOf('#');
|
|
86
|
+
if (hashIndex !== -1)
|
|
87
|
+
normalized = normalized.substring(0, hashIndex);
|
|
88
|
+
while (normalized.length > 1 && normalized.endsWith('/'))
|
|
89
|
+
normalized = normalized.slice(0, -1);
|
|
90
|
+
return normalized.toLowerCase();
|
|
91
|
+
}
|
|
92
|
+
function firstRelevantRank(retrievedUrls, relevantUrls) {
|
|
93
|
+
const relevant = new Set(relevantUrls.map(normalizeUrl));
|
|
94
|
+
for (let i = 0; i < retrievedUrls.length; i++) {
|
|
95
|
+
const url = retrievedUrls[i];
|
|
96
|
+
if (url && relevant.has(normalizeUrl(url)))
|
|
97
|
+
return i + 1;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function computeMetrics(results, kValues = exports.DEFAULT_K_VALUES) {
|
|
102
|
+
const recall = {};
|
|
103
|
+
for (const k of kValues) {
|
|
104
|
+
const hits = results.filter(r => r.rank !== null && r.rank <= k).length;
|
|
105
|
+
recall[k] = results.length ? hits / results.length : 0;
|
|
106
|
+
}
|
|
107
|
+
const reciprocalSum = results.reduce((sum, r) => sum + (r.rank ? 1 / r.rank : 0), 0);
|
|
108
|
+
return { cases: results.length, recall, mrr: results.length ? reciprocalSum / results.length : 0 };
|
|
109
|
+
}
|
|
110
|
+
// Accepts .jsonl (one case per line), .json (an array) or .yaml/.yml (a list).
|
|
111
|
+
function loadEvalCases(filePath) {
|
|
112
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
113
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
114
|
+
let parsed;
|
|
115
|
+
if (ext === '.jsonl') {
|
|
116
|
+
parsed = raw.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//')).map(l => JSON.parse(l));
|
|
117
|
+
}
|
|
118
|
+
else if (ext === '.json') {
|
|
119
|
+
parsed = JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
parsed = yaml.load(raw);
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(parsed)) {
|
|
125
|
+
throw new Error(`Eval file ${filePath} must contain a list of cases`);
|
|
126
|
+
}
|
|
127
|
+
return parsed.map((c, i) => {
|
|
128
|
+
if (!c || typeof c.query !== 'string' || !c.query.trim()) {
|
|
129
|
+
throw new Error(`Eval case #${i + 1} has no query`);
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(c.relevant_urls) || c.relevant_urls.length === 0) {
|
|
132
|
+
throw new Error(`Eval case #${i + 1} ("${c.query}") has no relevant_urls`);
|
|
133
|
+
}
|
|
134
|
+
return { ...c, id: c.id ?? String(i + 1) };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async function runEval(cases, deps, options) {
|
|
138
|
+
const results = [];
|
|
139
|
+
for (const evalCase of cases) {
|
|
140
|
+
const embedding = await deps.embed(evalCase.query);
|
|
141
|
+
const chunks = await deps.retrieve(embedding, evalCase, options.topK);
|
|
142
|
+
const retrievedUrls = chunks.map(c => c.url ?? '');
|
|
143
|
+
results.push({
|
|
144
|
+
id: evalCase.id ?? '',
|
|
145
|
+
query: evalCase.query,
|
|
146
|
+
rank: firstRelevantRank(retrievedUrls, evalCase.relevant_urls),
|
|
147
|
+
retrieved_urls: retrievedUrls,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const kValues = (options.kValues ?? exports.DEFAULT_K_VALUES).filter(k => k <= options.topK);
|
|
151
|
+
return {
|
|
152
|
+
model: options.model,
|
|
153
|
+
top_k: options.topK,
|
|
154
|
+
created_at: new Date().toISOString(),
|
|
155
|
+
...computeMetrics(results, kValues),
|
|
156
|
+
results,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Keeps the first (best) chunk of each page, in order.
|
|
160
|
+
function onePerPage(chunks) {
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
return chunks.filter(chunk => {
|
|
163
|
+
const key = normalizeUrl(chunk.url ?? '');
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
return false;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
return true;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Reciprocal Rank Fusion: each list contributes 1 / (RRF_K + rank) per chunk.
|
|
171
|
+
// Rank-based, so dense distances and bm25 scores need no calibration.
|
|
172
|
+
function fuseRrf(lists) {
|
|
173
|
+
const scores = new Map();
|
|
174
|
+
for (const list of lists) {
|
|
175
|
+
list.forEach((chunk, index) => {
|
|
176
|
+
if (chunk.rowid === undefined)
|
|
177
|
+
return;
|
|
178
|
+
const entry = scores.get(chunk.rowid) ?? { chunk, score: 0 };
|
|
179
|
+
entry.score += 1 / (exports.RRF_K + index + 1);
|
|
180
|
+
scores.set(chunk.rowid, entry);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return [...scores.values()].sort((a, b) => b.score - a.score).map(e => e.chunk);
|
|
184
|
+
}
|
|
185
|
+
// Turns free text into an FTS5 OR-query of quoted terms, so punctuation in a
|
|
186
|
+
// question (colons, quotes, parentheses) cannot break the MATCH syntax.
|
|
187
|
+
function toFtsQuery(text) {
|
|
188
|
+
const terms = text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
189
|
+
return [...new Set(terms)].map(t => `"${t}"`).join(' OR ');
|
|
190
|
+
}
|
|
191
|
+
// Queries the database the MCP server's sqlite provider reads
|
|
192
|
+
// (mcp/src/server.ts); dense mode runs exactly its query, so the numbers
|
|
193
|
+
// reflect what an agent receives today.
|
|
194
|
+
function createSqliteRetriever(defaultDbPath, options = {}) {
|
|
195
|
+
const Database = require('better-sqlite3');
|
|
196
|
+
const sqliteVec = require('sqlite-vec');
|
|
197
|
+
const mode = options.mode ?? 'dense';
|
|
198
|
+
const connections = new Map();
|
|
199
|
+
const open = (dbPath) => {
|
|
200
|
+
let conn = connections.get(dbPath);
|
|
201
|
+
if (!conn) {
|
|
202
|
+
if (!fs.existsSync(dbPath))
|
|
203
|
+
throw new Error(`Database file not found at ${dbPath}`);
|
|
204
|
+
const db = new Database(dbPath, { readonly: true });
|
|
205
|
+
sqliteVec.load(db);
|
|
206
|
+
conn = { db };
|
|
207
|
+
connections.set(dbPath, conn);
|
|
208
|
+
}
|
|
209
|
+
return conn;
|
|
210
|
+
};
|
|
211
|
+
// The keyword index is built in memory from the stored chunk text, so an
|
|
212
|
+
// existing database can be evaluated without re-ingesting it.
|
|
213
|
+
const ftsFor = (conn) => {
|
|
214
|
+
if (!conn.fts) {
|
|
215
|
+
const fts = new Database(':memory:');
|
|
216
|
+
fts.exec(`CREATE VIRTUAL TABLE chunks USING fts5(content, url UNINDEXED, product_name UNINDEXED, version UNINDEXED)`);
|
|
217
|
+
const insert = fts.prepare('INSERT INTO chunks (rowid, content, url, product_name, version) VALUES (?, ?, ?, ?, ?)');
|
|
218
|
+
const rows = conn.db.prepare('SELECT rowid, content, url, product_name, version FROM vec_items').all();
|
|
219
|
+
fts.transaction(() => {
|
|
220
|
+
for (const r of rows)
|
|
221
|
+
insert.run(BigInt(r.rowid), r.content ?? '', r.url, r.product_name, r.version);
|
|
222
|
+
})();
|
|
223
|
+
conn.fts = fts;
|
|
224
|
+
}
|
|
225
|
+
return conn.fts;
|
|
226
|
+
};
|
|
227
|
+
const dense = (conn, queryEmbedding, evalCase, limit) => {
|
|
228
|
+
let query = `
|
|
229
|
+
SELECT rowid, url, distance, content
|
|
230
|
+
FROM vec_items
|
|
231
|
+
WHERE embedding MATCH @query_embedding`;
|
|
232
|
+
if (evalCase.product_name)
|
|
233
|
+
query += ` AND product_name = @product_name`;
|
|
234
|
+
if (evalCase.version)
|
|
235
|
+
query += ` AND version = @version`;
|
|
236
|
+
query += `
|
|
237
|
+
ORDER BY distance
|
|
238
|
+
LIMIT @top_k;`;
|
|
239
|
+
return conn.db.prepare(query).all({
|
|
240
|
+
query_embedding: new Float32Array(queryEmbedding),
|
|
241
|
+
product_name: evalCase.product_name,
|
|
242
|
+
version: evalCase.version,
|
|
243
|
+
top_k: limit,
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
const bm25 = (conn, evalCase, limit) => {
|
|
247
|
+
const match = toFtsQuery(evalCase.query);
|
|
248
|
+
if (!match)
|
|
249
|
+
return [];
|
|
250
|
+
let query = `SELECT rowid, url, content FROM chunks WHERE chunks MATCH @match`;
|
|
251
|
+
if (evalCase.product_name)
|
|
252
|
+
query += ` AND product_name = @product_name`;
|
|
253
|
+
if (evalCase.version)
|
|
254
|
+
query += ` AND version = @version`;
|
|
255
|
+
query += ` ORDER BY bm25(chunks) LIMIT @limit`;
|
|
256
|
+
return ftsFor(conn).prepare(query).all({
|
|
257
|
+
match,
|
|
258
|
+
product_name: evalCase.product_name,
|
|
259
|
+
version: evalCase.version,
|
|
260
|
+
limit,
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
return async (queryEmbedding, evalCase, topK) => {
|
|
264
|
+
const dbPath = evalCase.db ?? defaultDbPath;
|
|
265
|
+
if (!dbPath)
|
|
266
|
+
throw new Error(`No database for eval case ${evalCase.id}: pass --db or set "db" on the case`);
|
|
267
|
+
const conn = open(dbPath);
|
|
268
|
+
const rerankCandidates = options.rerankCandidates ?? exports.DEFAULT_RERANK_CANDIDATES;
|
|
269
|
+
const depth = Math.max(topK, options.candidates ?? (mode === 'dense' && !options.perPage ? topK : 50), options.rerank ? rerankCandidates : 0);
|
|
270
|
+
let chunks;
|
|
271
|
+
if (mode === 'bm25') {
|
|
272
|
+
chunks = bm25(conn, evalCase, depth);
|
|
273
|
+
}
|
|
274
|
+
else if (mode === 'hybrid') {
|
|
275
|
+
chunks = fuseRrf([dense(conn, queryEmbedding, evalCase, depth), bm25(conn, evalCase, depth)]);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
chunks = dense(conn, queryEmbedding, evalCase, depth);
|
|
279
|
+
}
|
|
280
|
+
if (options.rerank && chunks.length > 1) {
|
|
281
|
+
const pool = chunks.slice(0, rerankCandidates);
|
|
282
|
+
const scores = await options.rerank(evalCase.query, pool.map(c => c.content ?? ''));
|
|
283
|
+
const reranked = pool.map((c, i) => ({ c, s: scores[i] })).sort((a, b) => b.s - a.s).map(x => x.c);
|
|
284
|
+
chunks = [...reranked, ...chunks.slice(rerankCandidates)];
|
|
285
|
+
}
|
|
286
|
+
if (options.perPage)
|
|
287
|
+
chunks = onePerPage(chunks);
|
|
288
|
+
return chunks.slice(0, topK);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Mirrors the provider selection in Doc2Vec's constructor and the MCP server's
|
|
292
|
+
// env vars, so the query is embedded with the model the database was built with.
|
|
293
|
+
function createEmbedder(embeddingConfig) {
|
|
294
|
+
const provider = embeddingConfig?.provider || process.env.EMBEDDING_PROVIDER || 'openai';
|
|
295
|
+
let client;
|
|
296
|
+
let model;
|
|
297
|
+
if (provider === 'azure') {
|
|
298
|
+
const apiKey = embeddingConfig?.azure?.api_key || process.env.AZURE_OPENAI_KEY;
|
|
299
|
+
const endpoint = embeddingConfig?.azure?.endpoint || process.env.AZURE_OPENAI_ENDPOINT;
|
|
300
|
+
model = embeddingConfig?.azure?.deployment_name || process.env.AZURE_OPENAI_DEPLOYMENT_NAME || 'text-embedding-3-large';
|
|
301
|
+
if (!apiKey || !endpoint)
|
|
302
|
+
throw new Error('Azure OpenAI requires api_key and endpoint to be configured');
|
|
303
|
+
client = new openai_1.AzureOpenAI({
|
|
304
|
+
apiKey,
|
|
305
|
+
endpoint,
|
|
306
|
+
deployment: model,
|
|
307
|
+
apiVersion: embeddingConfig?.azure?.api_version || process.env.AZURE_OPENAI_API_VERSION || '2024-10-21',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
const apiKey = embeddingConfig?.openai?.api_key || process.env.OPENAI_API_KEY;
|
|
312
|
+
const baseURL = embeddingConfig?.openai?.base_url || process.env.OPENAI_BASE_URL;
|
|
313
|
+
model = embeddingConfig?.openai?.model || process.env.OPENAI_MODEL || 'text-embedding-3-large';
|
|
314
|
+
if (!apiKey)
|
|
315
|
+
throw new Error('OpenAI requires api_key to be configured');
|
|
316
|
+
client = new openai_1.OpenAI({ apiKey, ...(baseURL && { baseURL }) });
|
|
317
|
+
}
|
|
318
|
+
const embed = async (text) => {
|
|
319
|
+
const response = await client.embeddings.create({ model, input: text });
|
|
320
|
+
return response.data[0].embedding;
|
|
321
|
+
};
|
|
322
|
+
return { embed, model };
|
|
323
|
+
}
|
|
324
|
+
function formatReport(report) {
|
|
325
|
+
const lines = [];
|
|
326
|
+
lines.push(`Model: ${report.model} Cases: ${report.cases} top_k: ${report.top_k}`);
|
|
327
|
+
const recallParts = Object.entries(report.recall).map(([k, v]) => `recall@${k} ${(v * 100).toFixed(1)}%`);
|
|
328
|
+
lines.push(`${recallParts.join(' ')} MRR ${report.mrr.toFixed(3)}`);
|
|
329
|
+
const misses = report.results.filter(r => r.rank === null);
|
|
330
|
+
if (misses.length) {
|
|
331
|
+
lines.push('');
|
|
332
|
+
lines.push(`Misses (${misses.length}):`);
|
|
333
|
+
for (const m of misses)
|
|
334
|
+
lines.push(` [${m.id}] ${m.query}`);
|
|
335
|
+
}
|
|
336
|
+
return lines.join('\n');
|
|
337
|
+
}
|
|
338
|
+
async function runEvalCommand(options) {
|
|
339
|
+
const mode = options.mode ?? 'dense';
|
|
340
|
+
if (!['dense', 'bm25', 'hybrid'].includes(mode)) {
|
|
341
|
+
throw new Error(`Unknown mode '${mode}': use dense, bm25 or hybrid`);
|
|
342
|
+
}
|
|
343
|
+
let rerank;
|
|
344
|
+
if (options.rerank) {
|
|
345
|
+
if (options.rerank !== 'deepinfra')
|
|
346
|
+
throw new Error(`Unknown reranker '${options.rerank}': use deepinfra`);
|
|
347
|
+
const apiKey = process.env.DEEPINFRA_API_KEY;
|
|
348
|
+
if (!apiKey)
|
|
349
|
+
throw new Error('--rerank deepinfra requires the DEEPINFRA_API_KEY environment variable');
|
|
350
|
+
rerank = createDeepInfraReranker(apiKey, options.rerankModel);
|
|
351
|
+
}
|
|
352
|
+
let embeddingConfig;
|
|
353
|
+
if (options.configPath) {
|
|
354
|
+
const config = yaml.load(fs.readFileSync(options.configPath, 'utf8'));
|
|
355
|
+
embeddingConfig = config?.embedding;
|
|
356
|
+
}
|
|
357
|
+
// Keyword-only retrieval never looks at the query vector, so skip the API call
|
|
358
|
+
const { embed, model } = mode === 'bm25'
|
|
359
|
+
? { embed: async () => [], model: 'none (bm25)' }
|
|
360
|
+
: createEmbedder(embeddingConfig);
|
|
361
|
+
const cases = loadEvalCases(options.casesPath);
|
|
362
|
+
const retrieve = createSqliteRetriever(options.dbPath, {
|
|
363
|
+
mode, perPage: options.perPage, rerank, rerankCandidates: options.rerankCandidates,
|
|
364
|
+
});
|
|
365
|
+
const label = `${model}, ${mode}` +
|
|
366
|
+
`${rerank ? `, reranked by ${options.rerankModel ?? exports.DEFAULT_RERANK_MODEL} over ${options.rerankCandidates ?? exports.DEFAULT_RERANK_CANDIDATES}` : ''}` +
|
|
367
|
+
`${options.perPage ? ', one chunk per page' : ''}`;
|
|
368
|
+
const report = await runEval(cases, { embed, retrieve }, { topK: options.topK, model: label });
|
|
369
|
+
console.log(formatReport(report));
|
|
370
|
+
if (options.outPath) {
|
|
371
|
+
fs.writeFileSync(options.outPath, JSON.stringify(report, null, 2));
|
|
372
|
+
console.log(`\nReport written to ${options.outPath}`);
|
|
373
|
+
}
|
|
374
|
+
return report;
|
|
375
|
+
}
|