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 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 { client: qdrantClient, collectionName, type: 'qdrant' };
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 async createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension) {
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.metadata.chunk_id;
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: embedding,
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
- const forceFullSync = syncCompleteValue !== 'true';
645
- if (forceFullSync) {
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
- const lastSyncTimestamp = parseInt(lastSyncValue || '0', 10);
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')