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/README.md
CHANGED
|
@@ -267,6 +267,7 @@ Configuration is managed through two files:
|
|
|
267
267
|
* `qdrant_url`: (Optional) URL of your Qdrant instance. Defaults to `http://localhost:6333`.
|
|
268
268
|
* `qdrant_port`: (Optional) Port for the Qdrant REST API. Defaults to `443` if `qdrant_url` starts with `https`, otherwise `6333`.
|
|
269
269
|
* `collection_name`: (Optional) Name of the Qdrant collection to use. Defaults to `<product_name>_<version>` (lowercased, spaces replaced with underscores).
|
|
270
|
+
* `keyword_index`: (Optional) `true` to also store a BM25 keyword vector per chunk, so the MCP server can run hybrid search (`HYBRID_SEARCH=true`). Needs Qdrant 1.18 or later. See [Qdrant keyword index](#qdrant-keyword-index).
|
|
270
271
|
|
|
271
272
|
Optional embedding configuration:
|
|
272
273
|
* `embedding.provider`: Provider for embeddings (`openai` or `azure`).
|
|
@@ -485,6 +486,34 @@ The script will then:
|
|
|
485
486
|
7. Cleanup obsolete chunks.
|
|
486
487
|
8. Output detailed logs.
|
|
487
488
|
|
|
489
|
+
### Evaluating retrieval quality
|
|
490
|
+
|
|
491
|
+
`doc2vec eval` measures how well a SQLite database answers real questions, so a change to the embedding model, chunking or ranking can be judged by numbers. Each case is a question plus the URLs that should come back; the command embeds the question with the same model as the database, runs the same nearest-neighbour query as the MCP server, and reports recall@1/4/10 (share of questions with a relevant chunk in the top k) and MRR (mean reciprocal rank of the first relevant chunk).
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
doc2vec eval cases.jsonl --db ./istio.db --config config.yaml --top-k 10 --out report.json
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Cases can be `.jsonl` (one per line), `.json` (an array) or `.yaml`:
|
|
498
|
+
|
|
499
|
+
```json
|
|
500
|
+
{"id": "mtls-1", "query": "how do I enable strict mTLS for one namespace?", "relevant_urls": ["https://istio.io/latest/docs/tasks/security/authentication/mtls-migration/"], "product_name": "istio", "version": "latest"}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
`db`, `product_name` and `version` are optional per case. URLs match ignoring `#fragments`, trailing slashes and case. `--config` reads only the `embedding` section; without it the `OPENAI_*` / `AZURE_OPENAI_*` env vars are used.
|
|
504
|
+
|
|
505
|
+
Search methods can be compared on the same database without re-ingesting it:
|
|
506
|
+
|
|
507
|
+
- `--mode dense` (default): nearest chunks by embedding, the MCP server's query.
|
|
508
|
+
- `--mode bm25`: keyword ranking with SQLite FTS5, built in memory from the stored chunk text.
|
|
509
|
+
- `--mode hybrid`: dense and BM25 lists fused with Reciprocal Rank Fusion.
|
|
510
|
+
- `--per-page`: keep only the best chunk of each page, so the top results cover more pages.
|
|
511
|
+
- `--rerank deepinfra`: rerank the top `--rerank-candidates` chunks (default 40) with `--rerank-model` (default `nvidia/Nemotron-3.5-Rerank-8B-BF16`); needs `DEEPINFRA_API_KEY`.
|
|
512
|
+
|
|
513
|
+
These match the MCP server's retrieval settings (`HYBRID_SEARCH`, `ONE_CHUNK_PER_PAGE`, `RERANKER`; see `mcp/README.md`), so a configuration can be measured before it is deployed.
|
|
514
|
+
|
|
515
|
+
Results of comparing embedding models, search methods and rerankers are in [RETRIEVAL_ROADMAP.md](RETRIEVAL_ROADMAP.md).
|
|
516
|
+
|
|
488
517
|
## Docker Images and the Browser
|
|
489
518
|
|
|
490
519
|
Website sources are rendered with a real browser (Puppeteer). The `Dockerfile` builds two images so you can choose where that browser runs:
|
|
@@ -675,6 +704,21 @@ Server-sent events: `GET /api/events` (run and config updates) and `GET /api/run
|
|
|
675
704
|
* Uses `@qdrant/js-client-rest`.
|
|
676
705
|
* Requires `qdrant_url`, `qdrant_port`, `collection_name` and potentially `QDRANT_API_KEY`.
|
|
677
706
|
|
|
707
|
+
#### Qdrant point ids
|
|
708
|
+
|
|
709
|
+
Each chunk is stored as one point whose id comes from its page URL and its position on the page. Earlier versions derived the id from the chunk text, so the same text on two pages (a shared "Before you begin" section, for example) became a single point: one page lost the chunk, and both pages looked changed and were re-embedded on every sync.
|
|
710
|
+
|
|
711
|
+
After upgrading, the first sync of each **website** or **S3** source on Qdrant processes every page once (bypassing the lastmod/ETag or timestamp shortcuts) and rewrites only the pages whose chunks were merged; unchanged pages are not re-embedded. Local directories compare every file on each sync and repair themselves on the first run. GitHub issues, Zendesk and code chunks carry the issue, ticket or file in their text, so merges there are rare; any affected item is repaired the next time it changes. SQLite was never affected.
|
|
712
|
+
|
|
713
|
+
#### Qdrant keyword index
|
|
714
|
+
|
|
715
|
+
With `keyword_index: true`, each chunk is stored with two vectors: the embedding (the collection's unnamed vector, as before) and a sparse `bm25` vector that Qdrant computes from the chunk text with its built-in `qdrant/bm25` model (IDF applied at query time). The MCP server queries both when `HYBRID_SEARCH=true`.
|
|
716
|
+
|
|
717
|
+
* **New collections** are created with the `bm25` sparse vector.
|
|
718
|
+
* **Existing collections** get it added on the next sync (Qdrant 1.18+ vector schema update), and every chunk stored before then is backfilled from its stored text. Embeddings are not recomputed. The backfill only reads chunks that lack the vector, so it resumes if interrupted and costs one empty scroll once done.
|
|
719
|
+
* On a Qdrant older than 1.18 the sync logs a warning and stores chunks without the keyword vector.
|
|
720
|
+
* Turning the option off later stops writing the vector; the existing ones stay unused.
|
|
721
|
+
|
|
678
722
|
## PDF Processing
|
|
679
723
|
|
|
680
724
|
Doc2Vec includes built-in support for processing PDF files in both local directories and websites. PDF files are automatically detected by their `.pdf` extension and processed using [Mozilla's PDF.js](https://github.com/mozilla/pdf.js) library.
|
package/dist/database.js
CHANGED
|
@@ -82,9 +82,24 @@ class DatabaseManager {
|
|
|
82
82
|
const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
|
|
83
83
|
logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
|
|
84
84
|
const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
|
|
85
|
-
await this.createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension);
|
|
85
|
+
await this.createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension, !!params.keyword_index);
|
|
86
|
+
const connection = { client: qdrantClient, collectionName, type: 'qdrant' };
|
|
87
|
+
if (params.keyword_index) {
|
|
88
|
+
connection.keywordIndex = await this.ensureKeywordVectorQdrant(qdrantClient, collectionName, logger);
|
|
89
|
+
if (connection.keywordIndex) {
|
|
90
|
+
// Best-effort: a failed backfill must not fail the sync. New
|
|
91
|
+
// chunks still get the vector, and the next sync resumes
|
|
92
|
+
// with the chunks that lack it.
|
|
93
|
+
try {
|
|
94
|
+
await this.backfillKeywordVectorsQdrant(connection, logger);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
logger.warn(`Keyword vector backfill of ${collectionName} stopped early; it resumes on the next sync:`, error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
86
101
|
logger.info(`Qdrant connection established successfully`);
|
|
87
|
-
return
|
|
102
|
+
return connection;
|
|
88
103
|
}
|
|
89
104
|
else {
|
|
90
105
|
const errMsg = `Unsupported database type: ${dbConfig.type}`;
|
|
@@ -92,7 +107,71 @@ class DatabaseManager {
|
|
|
92
107
|
throw new Error(errMsg);
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
|
-
static
|
|
110
|
+
static keywordDocument(text) {
|
|
111
|
+
return { text, model: DatabaseManager.KEYWORD_MODEL };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Adds the keyword vector to a collection that predates it. Needs Qdrant
|
|
115
|
+
* 1.18+ (vector schema updates); on an older server this logs a warning and
|
|
116
|
+
* returns false, and chunks are stored without the keyword vector.
|
|
117
|
+
*/
|
|
118
|
+
static async ensureKeywordVectorQdrant(qdrantClient, collectionName, logger) {
|
|
119
|
+
try {
|
|
120
|
+
const info = await qdrantClient.getCollection(collectionName);
|
|
121
|
+
const sparse = info?.config?.params?.sparse_vectors ?? {};
|
|
122
|
+
if (sparse[DatabaseManager.KEYWORD_VECTOR]) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
logger.info(`Adding keyword vector '${DatabaseManager.KEYWORD_VECTOR}' to collection ${collectionName}`);
|
|
126
|
+
await qdrantClient.createVectorName(collectionName, DatabaseManager.KEYWORD_VECTOR, { sparse: { modifier: 'idf' } });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
logger.warn(`Could not enable the keyword index on ${collectionName} (needs Qdrant 1.18 or later); storing chunks without it:`, error);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Gives the keyword vector to every chunk stored before it existed. Only
|
|
136
|
+
* points without it are read, so an interrupted backfill resumes on the
|
|
137
|
+
* next sync and a finished one costs a single empty scroll.
|
|
138
|
+
*/
|
|
139
|
+
static async backfillKeywordVectorsQdrant(db, logger) {
|
|
140
|
+
const { client, collectionName } = db;
|
|
141
|
+
const filter = {
|
|
142
|
+
must_not: [
|
|
143
|
+
{ has_vector: DatabaseManager.KEYWORD_VECTOR },
|
|
144
|
+
{ key: 'is_metadata', match: { value: true } },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
let updated = 0;
|
|
148
|
+
let offset = undefined;
|
|
149
|
+
do {
|
|
150
|
+
const page = await client.scroll(collectionName, {
|
|
151
|
+
filter,
|
|
152
|
+
limit: 256,
|
|
153
|
+
offset,
|
|
154
|
+
with_payload: ['content'],
|
|
155
|
+
with_vector: false,
|
|
156
|
+
});
|
|
157
|
+
const points = (page?.points ?? [])
|
|
158
|
+
.filter((p) => typeof p.payload?.content === 'string' && p.payload.content.trim().length > 0)
|
|
159
|
+
.map((p) => ({
|
|
160
|
+
id: p.id,
|
|
161
|
+
vector: { [DatabaseManager.KEYWORD_VECTOR]: DatabaseManager.keywordDocument(p.payload.content) },
|
|
162
|
+
}));
|
|
163
|
+
if (points.length > 0) {
|
|
164
|
+
await client.updateVectors(collectionName, { wait: true, points });
|
|
165
|
+
updated += points.length;
|
|
166
|
+
}
|
|
167
|
+
offset = page?.next_page_offset;
|
|
168
|
+
} while (offset !== undefined && offset !== null);
|
|
169
|
+
if (updated > 0) {
|
|
170
|
+
logger.info(`Backfilled the keyword vector for ${updated} chunks in ${collectionName}`);
|
|
171
|
+
}
|
|
172
|
+
return updated;
|
|
173
|
+
}
|
|
174
|
+
static async createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension, keywordIndex = false) {
|
|
96
175
|
try {
|
|
97
176
|
logger.debug(`Checking if collection ${collectionName} exists`);
|
|
98
177
|
const collections = await qdrantClient.getCollections();
|
|
@@ -107,6 +186,9 @@ class DatabaseManager {
|
|
|
107
186
|
size: embeddingDimension,
|
|
108
187
|
distance: "Cosine",
|
|
109
188
|
},
|
|
189
|
+
...(keywordIndex && {
|
|
190
|
+
sparse_vectors: { [DatabaseManager.KEYWORD_VECTOR]: { modifier: 'idf' } },
|
|
191
|
+
}),
|
|
110
192
|
});
|
|
111
193
|
logger.info(`Collection ${collectionName} created successfully`);
|
|
112
194
|
}
|
|
@@ -425,23 +507,42 @@ class DatabaseManager {
|
|
|
425
507
|
});
|
|
426
508
|
transaction();
|
|
427
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* One point per position on a page. chunk_id is a hash of the chunk text,
|
|
512
|
+
* so using it as the id merged the same text on two pages (or twice on one
|
|
513
|
+
* page) into a single point: one page lost the chunk, and both looked
|
|
514
|
+
* changed on every sync and were re-embedded. Every write path deletes a
|
|
515
|
+
* page's chunks by URL before inserting the new set, so (url, chunk_index)
|
|
516
|
+
* is unique and stable enough. Points written under the old scheme keep
|
|
517
|
+
* their ids until their page changes; deletes go by URL or by the ids read
|
|
518
|
+
* back, so the two schemes coexist.
|
|
519
|
+
*/
|
|
520
|
+
static qdrantPointId(chunk) {
|
|
521
|
+
const { url, chunk_index, chunk_id } = chunk.metadata;
|
|
522
|
+
if (url && typeof chunk_index === 'number') {
|
|
523
|
+
return utils_1.Utils.hashToUuid(utils_1.Utils.generateHash(`${url}\u0000${chunk_index}`));
|
|
524
|
+
}
|
|
525
|
+
return utils_1.Utils.isValidUuid(chunk_id) ? chunk_id : utils_1.Utils.hashToUuid(chunk_id);
|
|
526
|
+
}
|
|
428
527
|
static async storeChunkInQdrant(db, chunk, embedding, chunkHash) {
|
|
429
528
|
const { client, collectionName } = db;
|
|
430
529
|
try {
|
|
431
530
|
let pointId;
|
|
432
531
|
try {
|
|
433
|
-
pointId = chunk
|
|
434
|
-
if (!utils_1.Utils.isValidUuid(pointId)) {
|
|
435
|
-
pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
|
|
436
|
-
}
|
|
532
|
+
pointId = DatabaseManager.qdrantPointId(chunk);
|
|
437
533
|
}
|
|
438
534
|
catch (e) {
|
|
439
535
|
pointId = crypto_1.default.randomUUID();
|
|
440
536
|
}
|
|
441
537
|
const hash = chunkHash || utils_1.Utils.generateHash(chunk.content);
|
|
538
|
+
// With a keyword index the point carries both vectors: the unnamed
|
|
539
|
+
// dense one (named "" in a vector map) and the BM25 one
|
|
540
|
+
const vector = db.keywordIndex
|
|
541
|
+
? { '': embedding, [DatabaseManager.KEYWORD_VECTOR]: DatabaseManager.keywordDocument(chunk.content) }
|
|
542
|
+
: embedding;
|
|
442
543
|
const pointItem = {
|
|
443
544
|
id: pointId,
|
|
444
|
-
vector
|
|
545
|
+
vector,
|
|
445
546
|
payload: {
|
|
446
547
|
content: chunk.content,
|
|
447
548
|
product_name: chunk.metadata.product_name,
|
|
@@ -868,3 +969,8 @@ class DatabaseManager {
|
|
|
868
969
|
}
|
|
869
970
|
exports.DatabaseManager = DatabaseManager;
|
|
870
971
|
DatabaseManager.columnCache = new WeakMap();
|
|
972
|
+
// Qdrant computes the BM25 keyword vector itself from the chunk text
|
|
973
|
+
// (the `qdrant/bm25` model) and applies IDF at query time (`idf` modifier).
|
|
974
|
+
// The MCP server queries the same vector name with the same model.
|
|
975
|
+
DatabaseManager.KEYWORD_VECTOR = 'bm25';
|
|
976
|
+
DatabaseManager.KEYWORD_MODEL = 'qdrant/bm25';
|
package/dist/doc2vec.js
CHANGED
|
@@ -641,8 +641,18 @@ class Doc2Vec {
|
|
|
641
641
|
// remaining pages to be skipped indefinitely.
|
|
642
642
|
const syncCompleteKey = `sync_complete:${urlPrefix}`;
|
|
643
643
|
const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
// Qdrant point ids used to come from the chunk text, which merged the
|
|
645
|
+
// same chunk on two pages into one point (see qdrantPointId). One pass
|
|
646
|
+
// over every page rewrites the pages that lost chunks; pages whose
|
|
647
|
+
// chunks are all there are skipped by the hash comparison as usual.
|
|
648
|
+
const pointIdKey = `point_ids_by_position:${urlPrefix}`;
|
|
649
|
+
const repairPointIds = dbConnection.type === 'qdrant'
|
|
650
|
+
&& (await database_1.DatabaseManager.getMetadataValue(dbConnection, pointIdKey, undefined, logger)) !== 'true';
|
|
651
|
+
const forceFullSync = syncCompleteValue !== 'true' || repairPointIds;
|
|
652
|
+
if (forceFullSync && syncCompleteValue === 'true') {
|
|
653
|
+
logger.info('Processing all pages once to restore chunks shared between pages (per-page Qdrant point ids); unchanged pages are not re-embedded');
|
|
654
|
+
}
|
|
655
|
+
else if (forceFullSync) {
|
|
646
656
|
logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
|
|
647
657
|
}
|
|
648
658
|
const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
|
|
@@ -722,6 +732,9 @@ class Doc2Vec {
|
|
|
722
732
|
// unset and the next run will force-process all pages again.
|
|
723
733
|
if (forceFullSync) {
|
|
724
734
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
|
|
735
|
+
if (repairPointIds) {
|
|
736
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, pointIdKey, 'true', logger, this.embeddingDimension);
|
|
737
|
+
}
|
|
725
738
|
logger.info('Full sync completed successfully — subsequent runs will use normal caching');
|
|
726
739
|
}
|
|
727
740
|
let removed = { items: 0, chunks: 0 };
|
|
@@ -855,7 +868,15 @@ class Doc2Vec {
|
|
|
855
868
|
const lastSyncKey = `s3_last_sync_${bucketKey}`;
|
|
856
869
|
const fileListKey = `s3_filelist_${bucketKey}`;
|
|
857
870
|
const lastSyncValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, lastSyncKey, '0', logger);
|
|
858
|
-
|
|
871
|
+
// One pass over every object to restore chunks that text-derived
|
|
872
|
+
// Qdrant point ids merged across files (see processWebsite)
|
|
873
|
+
const pointIdKey = `point_ids_by_position:s3_${bucketKey}`;
|
|
874
|
+
const repairPointIds = dbConnection.type === 'qdrant'
|
|
875
|
+
&& (await database_1.DatabaseManager.getMetadataValue(dbConnection, pointIdKey, undefined, logger)) !== 'true';
|
|
876
|
+
if (repairPointIds && lastSyncValue && lastSyncValue !== '0') {
|
|
877
|
+
logger.info('Processing all objects once to restore chunks shared between files (per-page Qdrant point ids)');
|
|
878
|
+
}
|
|
879
|
+
const lastSyncTimestamp = repairPointIds ? 0 : parseInt(lastSyncValue || '0', 10);
|
|
859
880
|
const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm', '.pdf', '.doc', '.docx'];
|
|
860
881
|
const excludeExtensions = config.exclude_extensions || [];
|
|
861
882
|
const encoding = config.encoding || 'utf8';
|
|
@@ -1034,6 +1055,9 @@ class Doc2Vec {
|
|
|
1034
1055
|
const currentKeys = Array.from(processedFiles);
|
|
1035
1056
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentKeys), logger, this.embeddingDimension);
|
|
1036
1057
|
await database_1.DatabaseManager.setMetadataValue(dbConnection, lastSyncKey, `${syncStartTimestamp}`, logger, this.embeddingDimension);
|
|
1058
|
+
if (repairPointIds) {
|
|
1059
|
+
await database_1.DatabaseManager.setMetadataValue(dbConnection, pointIdKey, 'true', logger, this.embeddingDimension);
|
|
1060
|
+
}
|
|
1037
1061
|
logger.info(`Finished processing S3 bucket: ${config.bucket}`);
|
|
1038
1062
|
}
|
|
1039
1063
|
/**
|
|
@@ -1910,6 +1934,33 @@ if (require.main === module) {
|
|
|
1910
1934
|
.option('--source <product_name>', 'only sync sources with this product_name (repeatable)', (value, previous) => [...previous, value], [])
|
|
1911
1935
|
.option('--source-index <n>', 'only sync the source at this 0-based position in the config (repeatable, exact)', (value, previous) => [...previous, parseInt(value, 10)], [])
|
|
1912
1936
|
.action((configPath, options) => runOneShot(configPath, { names: options.source, indices: options.sourceIndex }));
|
|
1937
|
+
program
|
|
1938
|
+
.command('eval <cases>')
|
|
1939
|
+
.description('Measure retrieval quality (recall@k, MRR) of a sqlite-vec database against questions with known relevant URLs')
|
|
1940
|
+
.option('--db <path>', 'sqlite database to query (cases may override it with a "db" field)')
|
|
1941
|
+
.option('--config <path>', 'config file whose embedding section built the database (default: env vars)')
|
|
1942
|
+
.option('--top-k <n>', 'number of chunks retrieved per question', (v) => parseInt(v, 10), 10)
|
|
1943
|
+
.option('--out <path>', 'write the full JSON report here')
|
|
1944
|
+
.option('--mode <mode>', "retrieval: 'dense' (vector, as the MCP server), 'bm25' (keyword) or 'hybrid' (both, fused with RRF)", 'dense')
|
|
1945
|
+
.option('--per-page', 'keep only the best chunk of each page in the results', false)
|
|
1946
|
+
.option('--rerank <provider>', "rerank the top candidates: 'deepinfra' (needs DEEPINFRA_API_KEY)")
|
|
1947
|
+
.option('--rerank-model <model>', 'reranker model (default: nvidia/Nemotron-3.5-Rerank-8B-BF16)')
|
|
1948
|
+
.option('--rerank-candidates <n>', 'how many top chunks the reranker sees (default: 40)', (v) => parseInt(v, 10))
|
|
1949
|
+
.action(async (casesPath, options) => {
|
|
1950
|
+
// Lazy-require so the sync path doesn't load the eval module
|
|
1951
|
+
const { runEvalCommand } = require('./eval');
|
|
1952
|
+
try {
|
|
1953
|
+
await runEvalCommand({
|
|
1954
|
+
casesPath, dbPath: options.db, configPath: options.config, topK: options.topK, outPath: options.out,
|
|
1955
|
+
mode: options.mode, perPage: options.perPage,
|
|
1956
|
+
rerank: options.rerank, rerankModel: options.rerankModel, rerankCandidates: options.rerankCandidates,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
catch (err) {
|
|
1960
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1961
|
+
process.exit(1);
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1913
1964
|
program
|
|
1914
1965
|
.command('controller [configs...]')
|
|
1915
1966
|
.description('Run as a long-lived controller: schedule sync jobs per config file, persist runs in Postgres, serve a web UI')
|