doc2vec 2.2.0 → 2.4.0

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/database.js CHANGED
@@ -374,10 +374,10 @@ class DatabaseManager {
374
374
  chunk.metadata.version
375
375
  ];
376
376
  if (hasBranchColumn) {
377
- insertValues.push(chunk.metadata.branch ?? null);
377
+ insertValues.push(chunk.metadata.branch ?? '');
378
378
  }
379
379
  if (hasRepoColumn) {
380
- insertValues.push(chunk.metadata.repo ?? null);
380
+ insertValues.push(chunk.metadata.repo ?? '');
381
381
  }
382
382
  insertValues.push(JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.metadata.chunk_id, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks);
383
383
  insertStmt.run(...insertValues);
@@ -389,10 +389,10 @@ class DatabaseManager {
389
389
  chunk.metadata.version
390
390
  ];
391
391
  if (hasBranchColumn) {
392
- updateValues.push(chunk.metadata.branch ?? null);
392
+ updateValues.push(chunk.metadata.branch ?? '');
393
393
  }
394
394
  if (hasRepoColumn) {
395
- updateValues.push(chunk.metadata.repo ?? null);
395
+ updateValues.push(chunk.metadata.repo ?? '');
396
396
  }
397
397
  updateValues.push(JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks, chunk.metadata.chunk_id);
398
398
  updateStmt.run(...updateValues);
@@ -535,7 +535,7 @@ class DatabaseManager {
535
535
  {
536
536
  key: 'url',
537
537
  match: {
538
- text: url
538
+ value: url // Exact match — must match getChunkHashesByUrlQdrant's filter
539
539
  }
540
540
  }
541
541
  ],
@@ -555,6 +555,96 @@ class DatabaseManager {
555
555
  logger.error(`Error deleting chunks from Qdrant for URL ${url}:`, error);
556
556
  }
557
557
  }
558
+ /**
559
+ * Fetch all distinct URLs stored under a given URL prefix.
560
+ * Used to pre-seed the crawl queue on re-runs so link discovery isn't lost
561
+ * when pages are skipped via ETag matching.
562
+ */
563
+ static getStoredUrlsByPrefixSQLite(db, urlPrefix) {
564
+ const stmt = db.prepare('SELECT DISTINCT url FROM vec_items WHERE url LIKE ? || \'%\'');
565
+ const rows = stmt.all(urlPrefix);
566
+ return rows.map(r => r.url);
567
+ }
568
+ static async getStoredUrlsByPrefixQdrant(db, urlPrefix) {
569
+ const { client, collectionName } = db;
570
+ try {
571
+ const response = await client.scroll(collectionName, {
572
+ limit: 10000,
573
+ with_payload: { include: ['url'] },
574
+ with_vector: false,
575
+ filter: {
576
+ must: [
577
+ {
578
+ key: 'url',
579
+ match: { text: urlPrefix }
580
+ }
581
+ ],
582
+ must_not: [
583
+ {
584
+ key: 'is_metadata',
585
+ match: { value: true }
586
+ }
587
+ ]
588
+ }
589
+ });
590
+ const urls = new Set();
591
+ for (const point of response.points) {
592
+ const url = point.payload?.url;
593
+ if (url && url.startsWith(urlPrefix)) {
594
+ urls.add(url);
595
+ }
596
+ }
597
+ return Array.from(urls);
598
+ }
599
+ catch (error) {
600
+ return [];
601
+ }
602
+ }
603
+ /**
604
+ * Fetch all stored chunk content hashes for a given URL.
605
+ * Returns a sorted array of hash strings for comparison.
606
+ */
607
+ static getChunkHashesByUrlSQLite(db, url) {
608
+ const stmt = db.prepare('SELECT hash FROM vec_items WHERE url = ?');
609
+ const rows = stmt.all(url);
610
+ return rows.map(r => r.hash).sort();
611
+ }
612
+ /**
613
+ * Fetch all stored chunk content hashes for a given URL from Qdrant.
614
+ * Returns a sorted array of hash strings for comparison.
615
+ */
616
+ static async getChunkHashesByUrlQdrant(db, url) {
617
+ const { client, collectionName } = db;
618
+ try {
619
+ const response = await client.scroll(collectionName, {
620
+ limit: 10000,
621
+ with_payload: { include: ['hash'] },
622
+ with_vector: false,
623
+ filter: {
624
+ must: [
625
+ {
626
+ key: 'url',
627
+ match: { value: url }
628
+ }
629
+ ],
630
+ must_not: [
631
+ {
632
+ key: 'is_metadata',
633
+ match: { value: true }
634
+ }
635
+ ]
636
+ }
637
+ });
638
+ return response.points
639
+ .map((point) => point.payload?.hash)
640
+ .filter(Boolean)
641
+ .sort();
642
+ }
643
+ catch (error) {
644
+ // On error, return empty array so the caller treats it as "changed" (safe default)
645
+ return [];
646
+ }
647
+ }
558
648
  static removeObsoleteFilesSQLite(db, processedFiles, pathConfig, logger) {
559
649
  const getChunksForPathStmt = db.prepare(`
560
650
  SELECT chunk_id, url FROM vec_items
package/dist/doc2vec.js CHANGED
@@ -391,86 +391,63 @@ class Doc2Vec {
391
391
  const logger = parentLogger.child('process');
392
392
  logger.info(`Starting processing for website: ${config.url}`);
393
393
  const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
394
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
394
395
  const validChunkIds = new Set();
395
396
  const visitedUrls = new Set();
396
397
  const urlPrefix = utils_1.Utils.getUrlPrefix(config.url);
397
398
  logger.section('CRAWL AND EMBEDDING');
399
+ // Pre-load known URLs from the database so the queue includes pages from
400
+ // previous runs. This ensures link discovery isn't lost when pages are
401
+ // skipped via ETag matching.
402
+ let knownUrls;
403
+ if (dbConnection.type === 'sqlite') {
404
+ const urls = database_1.DatabaseManager.getStoredUrlsByPrefixSQLite(dbConnection.db, urlPrefix);
405
+ if (urls.length > 0) {
406
+ knownUrls = new Set(urls);
407
+ logger.info(`Found ${urls.length} known URLs in database for pre-seeding`);
408
+ }
409
+ }
410
+ else if (dbConnection.type === 'qdrant') {
411
+ const urls = await database_1.DatabaseManager.getStoredUrlsByPrefixQdrant(dbConnection, urlPrefix);
412
+ if (urls.length > 0) {
413
+ knownUrls = new Set(urls);
414
+ logger.info(`Found ${urls.length} known URLs in Qdrant for pre-seeding`);
415
+ }
416
+ }
417
+ // ETag store for caching page ETags across runs
418
+ const etagStore = {
419
+ get: async (url) => {
420
+ return database_1.DatabaseManager.getMetadataValue(dbConnection, `etag:${url}`, undefined, logger);
421
+ },
422
+ set: async (url, etag) => {
423
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger);
424
+ },
425
+ };
426
+ const lastmodStore = {
427
+ get: async (url) => {
428
+ return database_1.DatabaseManager.getMetadataValue(dbConnection, `lastmod:${url}`, undefined, logger);
429
+ },
430
+ set: async (url, lastmod) => {
431
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger);
432
+ },
433
+ };
398
434
  const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
399
435
  visitedUrls.add(url);
400
436
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
401
437
  try {
402
438
  const chunks = await this.contentProcessor.chunkMarkdown(content, config, url);
403
439
  logger.info(`Created ${chunks.length} chunks`);
404
- if (chunks.length > 0) {
405
- const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
406
- for (let i = 0; i < chunks.length; i++) {
407
- const chunk = chunks[i];
408
- validChunkIds.add(chunk.metadata.chunk_id);
409
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
410
- let needsEmbedding = true;
411
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
412
- if (dbConnection.type === 'sqlite') {
413
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
414
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
415
- if (existing && existing.hash === chunkHash) {
416
- needsEmbedding = false;
417
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
418
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
419
- }
420
- }
421
- else if (dbConnection.type === 'qdrant') {
422
- try {
423
- let pointId;
424
- try {
425
- pointId = chunk.metadata.chunk_id;
426
- if (!utils_1.Utils.isValidUuid(pointId)) {
427
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
428
- }
429
- }
430
- catch (e) {
431
- pointId = crypto_1.default.randomUUID();
432
- }
433
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
434
- ids: [pointId],
435
- with_payload: true,
436
- with_vector: false,
437
- });
438
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
439
- needsEmbedding = false;
440
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
441
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
442
- }
443
- }
444
- catch (error) {
445
- logger.error(`Error checking existing point in Qdrant:`, error);
446
- }
447
- }
448
- if (needsEmbedding) {
449
- const embeddings = await this.createEmbeddings([chunk.content]);
450
- if (embeddings.length > 0) {
451
- const embedding = embeddings[0];
452
- if (dbConnection.type === 'sqlite') {
453
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
454
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
455
- }
456
- else if (dbConnection.type === 'qdrant') {
457
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
458
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
459
- }
460
- }
461
- else {
462
- logger.error(`Embedding failed for chunk: ${chunkId}`);
463
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
464
- }
465
- }
466
- }
467
- chunkProgress.complete();
440
+ for (const chunk of chunks) {
441
+ validChunkIds.add(chunk.metadata.chunk_id);
468
442
  }
443
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
444
+ return true;
469
445
  }
470
446
  catch (error) {
471
447
  logger.error(`Error during chunking or embedding for ${url}:`, error);
448
+ return false;
472
449
  }
473
- }, logger, visitedUrls);
450
+ }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
474
451
  this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
475
452
  this.writeBrokenLinksReport();
476
453
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
@@ -560,71 +537,10 @@ class Doc2Vec {
560
537
  }
561
538
  const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
562
539
  logger.info(`Created ${chunks.length} chunks`);
563
- if (chunks.length > 0) {
564
- const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
565
- for (let i = 0; i < chunks.length; i++) {
566
- const chunk = chunks[i];
567
- validChunkIds.add(chunk.metadata.chunk_id);
568
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
569
- let needsEmbedding = true;
570
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
571
- if (dbConnection.type === 'sqlite') {
572
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
573
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
574
- if (existing && existing.hash === chunkHash) {
575
- needsEmbedding = false;
576
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
577
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
578
- }
579
- }
580
- else if (dbConnection.type === 'qdrant') {
581
- try {
582
- let pointId;
583
- try {
584
- pointId = chunk.metadata.chunk_id;
585
- if (!utils_1.Utils.isValidUuid(pointId)) {
586
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
587
- }
588
- }
589
- catch (e) {
590
- pointId = crypto_1.default.randomUUID();
591
- }
592
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
593
- ids: [pointId],
594
- with_payload: true,
595
- with_vector: false,
596
- });
597
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
598
- needsEmbedding = false;
599
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
600
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
601
- }
602
- }
603
- catch (error) {
604
- logger.error(`Error checking existing point in Qdrant:`, error);
605
- }
606
- }
607
- if (needsEmbedding) {
608
- const embeddings = await this.createEmbeddings([chunk.content]);
609
- if (embeddings.length > 0) {
610
- const embedding = embeddings[0];
611
- if (dbConnection.type === 'sqlite') {
612
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
613
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
614
- }
615
- else if (dbConnection.type === 'qdrant') {
616
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
617
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
618
- }
619
- }
620
- else {
621
- logger.error(`Embedding failed for chunk: ${chunkId}`);
622
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
623
- }
624
- }
625
- }
626
- chunkProgress.complete();
540
+ for (const chunk of chunks) {
541
+ validChunkIds.add(chunk.metadata.chunk_id);
627
542
  }
543
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
628
544
  }
629
545
  catch (error) {
630
546
  logger.error(`Error during chunking or embedding for ${filePath}:`, error);
@@ -728,71 +644,10 @@ class Doc2Vec {
728
644
  try {
729
645
  const chunks = await this.contentProcessor.chunkCode(content, config, fileUrl, relativePath || filePath, repoBranch || config.branch, config.repo);
730
646
  logger.info(`Created ${chunks.length} chunks`);
731
- if (chunks.length > 0) {
732
- const chunkProgress = logger.progress(`Embedding chunks for ${relativePath || filePath}`, chunks.length);
733
- for (let i = 0; i < chunks.length; i++) {
734
- const chunk = chunks[i];
735
- validChunkIds.add(chunk.metadata.chunk_id);
736
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
737
- let needsEmbedding = true;
738
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
739
- if (dbConnection.type === 'sqlite') {
740
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
741
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
742
- if (existing && existing.hash === chunkHash) {
743
- needsEmbedding = false;
744
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
745
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
746
- }
747
- }
748
- else if (dbConnection.type === 'qdrant') {
749
- try {
750
- let pointId;
751
- try {
752
- pointId = chunk.metadata.chunk_id;
753
- if (!utils_1.Utils.isValidUuid(pointId)) {
754
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
755
- }
756
- }
757
- catch (e) {
758
- pointId = crypto_1.default.randomUUID();
759
- }
760
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
761
- ids: [pointId],
762
- with_payload: true,
763
- with_vector: false,
764
- });
765
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
766
- needsEmbedding = false;
767
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
768
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
769
- }
770
- }
771
- catch (error) {
772
- logger.error(`Error checking existing point in Qdrant:`, error);
773
- }
774
- }
775
- if (needsEmbedding) {
776
- const embeddings = await this.createEmbeddings([chunk.content]);
777
- if (embeddings.length > 0) {
778
- const embedding = embeddings[0];
779
- if (dbConnection.type === 'sqlite') {
780
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
781
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
782
- }
783
- else if (dbConnection.type === 'qdrant') {
784
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
785
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
786
- }
787
- }
788
- else {
789
- logger.error(`Embedding failed for chunk: ${chunkId}`);
790
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
791
- }
792
- }
793
- }
794
- chunkProgress.complete();
647
+ for (const chunk of chunks) {
648
+ validChunkIds.add(chunk.metadata.chunk_id);
795
649
  }
650
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
796
651
  }
797
652
  catch (error) {
798
653
  logger.error(`Error during code chunking or embedding for ${filePath}:`, error);
@@ -1271,61 +1126,7 @@ class Doc2Vec {
1271
1126
  };
1272
1127
  const chunks = await this.contentProcessor.chunkMarkdown(markdown, articleConfig, url);
1273
1128
  logger.info(`Article #${articleId}: Created ${chunks.length} chunks`);
1274
- // Process and store each chunk (similar to ticket processing)
1275
- for (const chunk of chunks) {
1276
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
1277
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
1278
- if (dbConnection.type === 'sqlite') {
1279
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
1280
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
1281
- if (existing && existing.hash === chunkHash) {
1282
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1283
- continue;
1284
- }
1285
- const embeddings = await this.createEmbeddings([chunk.content]);
1286
- if (embeddings.length) {
1287
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
1288
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
1289
- }
1290
- else {
1291
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1292
- }
1293
- }
1294
- else if (dbConnection.type === 'qdrant') {
1295
- try {
1296
- let pointId;
1297
- try {
1298
- pointId = chunk.metadata.chunk_id;
1299
- if (!utils_1.Utils.isValidUuid(pointId)) {
1300
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
1301
- }
1302
- }
1303
- catch (e) {
1304
- pointId = crypto_1.default.randomUUID();
1305
- }
1306
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
1307
- ids: [pointId],
1308
- with_payload: true,
1309
- with_vector: false,
1310
- });
1311
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
1312
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1313
- continue;
1314
- }
1315
- const embeddings = await this.createEmbeddings([chunk.content]);
1316
- if (embeddings.length) {
1317
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
1318
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
1319
- }
1320
- else {
1321
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1322
- }
1323
- }
1324
- catch (error) {
1325
- logger.error(`Error processing chunk in Qdrant:`, error);
1326
- }
1327
- }
1328
- }
1129
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
1329
1130
  };
1330
1131
  logger.info(`Fetching Zendesk help center articles updated since ${startDate}`);
1331
1132
  let nextPage = `${baseUrl}/articles.json`;
@@ -1356,13 +1157,98 @@ class Doc2Vec {
1356
1157
  }
1357
1158
  logger.info(`Successfully processed ${processedArticles} of ${totalArticles} articles (filtered by date >= ${startDate})`);
1358
1159
  }
1160
+ /**
1161
+ * Process chunks for a given URL with change detection.
1162
+ *
1163
+ * Compares the new chunk hashes against existing hashes stored in the DB.
1164
+ * - If all hashes match (same content, same count): skip entirely (no embedding, no DB writes).
1165
+ * - If any hash differs: delete all existing chunks for this URL and re-embed/insert all new chunks.
1166
+ *
1167
+ * This ensures chunk_index and total_chunks are always consistent, and no orphaned
1168
+ * chunks are left behind when content shifts (e.g., a paragraph is added in the middle).
1169
+ *
1170
+ * @returns The number of chunks that were embedded (0 if skipped).
1171
+ */
1172
+ async processChunksForUrl(chunks, url, dbConnection, logger) {
1173
+ if (chunks.length === 0)
1174
+ return 0;
1175
+ // 1. Compute hashes for all new chunks
1176
+ const newHashes = chunks.map(c => utils_1.Utils.generateHash(c.content));
1177
+ const newHashesSorted = newHashes.slice().sort();
1178
+ // 2. Fetch existing hashes for this URL from the DB
1179
+ let existingHashesSorted;
1180
+ if (dbConnection.type === 'sqlite') {
1181
+ existingHashesSorted = database_1.DatabaseManager.getChunkHashesByUrlSQLite(dbConnection.db, url);
1182
+ }
1183
+ else {
1184
+ existingHashesSorted = await database_1.DatabaseManager.getChunkHashesByUrlQdrant(dbConnection, url);
1185
+ }
1186
+ // 3. Compare: if identical sorted arrays, content is unchanged — skip
1187
+ const unchanged = newHashesSorted.length === existingHashesSorted.length &&
1188
+ newHashesSorted.every((h, i) => h === existingHashesSorted[i]);
1189
+ if (unchanged) {
1190
+ logger.info(`Skipping unchanged URL (${chunks.length} chunks): ${url}`);
1191
+ return 0;
1192
+ }
1193
+ // 4. Content changed — delete all existing chunks for this URL
1194
+ if (existingHashesSorted.length > 0) {
1195
+ logger.info(`Content changed for ${url} (${existingHashesSorted.length} old chunks → ${chunks.length} new chunks), re-embedding all`);
1196
+ if (dbConnection.type === 'sqlite') {
1197
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1198
+ }
1199
+ else {
1200
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1201
+ }
1202
+ }
1203
+ // 5. Embed and insert all new chunks
1204
+ let embeddedCount = 0;
1205
+ const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
1206
+ for (let i = 0; i < chunks.length; i++) {
1207
+ const chunk = chunks[i];
1208
+ const chunkHash = newHashes[i];
1209
+ const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
1210
+ const embeddings = await this.createEmbeddings([chunk.content]);
1211
+ if (embeddings.length > 0) {
1212
+ const embedding = embeddings[0];
1213
+ if (dbConnection.type === 'sqlite') {
1214
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
1215
+ chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
1216
+ }
1217
+ else if (dbConnection.type === 'qdrant') {
1218
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
1219
+ chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
1220
+ }
1221
+ embeddedCount++;
1222
+ }
1223
+ else {
1224
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
1225
+ chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
1226
+ }
1227
+ }
1228
+ chunkProgress.complete();
1229
+ return embeddedCount;
1230
+ }
1359
1231
  async createEmbeddings(texts) {
1360
1232
  const logger = this.logger.child('embeddings');
1361
1233
  try {
1362
- logger.debug(`Creating embeddings for ${texts.length} texts`);
1234
+ // Truncate any texts that exceed the embedding model's token limit.
1235
+ // This is a safety net for pages with dense content (e.g., large API
1236
+ // reference pages with deeply nested config paths) where the chunker's
1237
+ // whitespace-based token count drastically underestimates BPE tokens.
1238
+ const safeTexts = texts.map(text => {
1239
+ if (text.length > Doc2Vec.MAX_EMBEDDING_CHARS) {
1240
+ const estimatedTokens = Math.ceil(text.length / Doc2Vec.CHARS_PER_TOKEN);
1241
+ logger.warn(`Truncating oversized chunk (${text.length} chars, ~${estimatedTokens} tokens) ` +
1242
+ `to ${Doc2Vec.MAX_EMBEDDING_CHARS} chars (~${Doc2Vec.MAX_EMBEDDING_TOKENS} tokens) ` +
1243
+ `to fit embedding model limit`);
1244
+ return text.substring(0, Doc2Vec.MAX_EMBEDDING_CHARS);
1245
+ }
1246
+ return text;
1247
+ });
1248
+ logger.debug(`Creating embeddings for ${safeTexts.length} texts`);
1363
1249
  const response = await this.openai.embeddings.create({
1364
1250
  model: this.embeddingModel,
1365
- input: texts,
1251
+ input: safeTexts,
1366
1252
  });
1367
1253
  logger.debug(`Successfully created ${response.data.length} embeddings`);
1368
1254
  return response.data.map(d => d.embedding);
@@ -1374,6 +1260,13 @@ class Doc2Vec {
1374
1260
  }
1375
1261
  }
1376
1262
  exports.Doc2Vec = Doc2Vec;
1263
+ // Embedding model token limit and character-based estimate.
1264
+ // OpenAI's text-embedding-3-large has an 8,191-token context limit.
1265
+ // Using ~4 chars per BPE token as a conservative estimate (actual average
1266
+ // is ~3.5 for English, lower for code/URLs/config paths).
1267
+ Doc2Vec.MAX_EMBEDDING_TOKENS = 8191;
1268
+ Doc2Vec.CHARS_PER_TOKEN = 4;
1269
+ Doc2Vec.MAX_EMBEDDING_CHARS = Doc2Vec.MAX_EMBEDDING_TOKENS * Doc2Vec.CHARS_PER_TOKEN;
1377
1270
  if (require.main === module) {
1378
1271
  const configPath = process.argv[2] || 'config.yaml';
1379
1272
  if (!fs.existsSync(configPath)) {
package/dist/utils.js CHANGED
@@ -81,6 +81,9 @@ class Utils {
81
81
  static shouldProcessUrl(url) {
82
82
  const parsedUrl = new URL(url);
83
83
  const pathname = parsedUrl.pathname;
84
+ // Paths ending with / are directory-like URLs (e.g., /app/2.1.x/), always process them
85
+ if (pathname.endsWith('/'))
86
+ return true;
84
87
  const ext = path.extname(pathname);
85
88
  if (!ext)
86
89
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",