doc2vec 2.3.0 → 2.5.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/doc2vec.js CHANGED
@@ -72,6 +72,7 @@ class Doc2Vec {
72
72
  // Check environment variable if not specified in config
73
73
  const embeddingProvider = this.config.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'openai';
74
74
  const embeddingConfig = this.config.embedding || { provider: embeddingProvider };
75
+ this.embeddingDimension = this.resolveEmbeddingDimension(embeddingConfig);
75
76
  if (embeddingProvider === 'azure') {
76
77
  const azureApiKey = embeddingConfig.azure?.api_key || process.env.AZURE_OPENAI_KEY;
77
78
  const azureEndpoint = embeddingConfig.azure?.endpoint || process.env.AZURE_OPENAI_ENDPOINT;
@@ -88,7 +89,7 @@ class Doc2Vec {
88
89
  apiVersion: azureApiVersion,
89
90
  });
90
91
  this.embeddingModel = azureDeploymentName;
91
- this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName}`);
92
+ this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName} (${this.embeddingDimension} dimensions)`);
92
93
  }
93
94
  else {
94
95
  const openaiApiKey = embeddingConfig.openai?.api_key || process.env.OPENAI_API_KEY;
@@ -99,7 +100,7 @@ class Doc2Vec {
99
100
  }
100
101
  this.openai = new openai_1.OpenAI({ apiKey: openaiApiKey });
101
102
  this.embeddingModel = openaiModel;
102
- this.logger.info(`Using OpenAI with model: ${openaiModel}`);
103
+ this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
103
104
  }
104
105
  this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
105
106
  }
@@ -144,6 +145,21 @@ class Doc2Vec {
144
145
  process.exit(1);
145
146
  }
146
147
  }
148
+ resolveEmbeddingDimension(embeddingConfig) {
149
+ const defaultDimension = 3072;
150
+ const rawConfigValue = embeddingConfig?.dimension;
151
+ const rawEnvValue = process.env.EMBEDDING_DIMENSION;
152
+ const candidate = rawConfigValue ?? (rawEnvValue ? Number(rawEnvValue) : undefined);
153
+ if (candidate === undefined) {
154
+ return defaultDimension;
155
+ }
156
+ const parsedValue = typeof candidate === 'string' ? Number(candidate) : candidate;
157
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0 || !Number.isInteger(parsedValue)) {
158
+ this.logger.warn(`Invalid embedding dimension provided (${candidate}), falling back to ${defaultDimension}`);
159
+ return defaultDimension;
160
+ }
161
+ return parsedValue;
162
+ }
147
163
  async run() {
148
164
  this.logger.section('PROCESSING SOURCES');
149
165
  for (const sourceConfig of this.config.sources) {
@@ -373,13 +389,13 @@ class Doc2Vec {
373
389
  await processIssue(issues[i]);
374
390
  }
375
391
  // Update the last run date in the database after processing all issues
376
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
392
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension);
377
393
  logger.info(`Successfully processed ${issues.length} issues`);
378
394
  }
379
395
  async processGithubRepo(config, parentLogger) {
380
396
  const logger = parentLogger.child('process');
381
397
  logger.info(`Starting processing for GitHub repo: ${config.repo}`);
382
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
398
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
383
399
  // Initialize metadata storage
384
400
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
385
401
  logger.section('GITHUB ISSUES');
@@ -390,87 +406,64 @@ class Doc2Vec {
390
406
  async processWebsite(config, parentLogger) {
391
407
  const logger = parentLogger.child('process');
392
408
  logger.info(`Starting processing for website: ${config.url}`);
393
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
409
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
410
+ await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
394
411
  const validChunkIds = new Set();
395
412
  const visitedUrls = new Set();
396
413
  const urlPrefix = utils_1.Utils.getUrlPrefix(config.url);
397
414
  logger.section('CRAWL AND EMBEDDING');
415
+ // Pre-load known URLs from the database so the queue includes pages from
416
+ // previous runs. This ensures link discovery isn't lost when pages are
417
+ // skipped via ETag matching.
418
+ let knownUrls;
419
+ if (dbConnection.type === 'sqlite') {
420
+ const urls = database_1.DatabaseManager.getStoredUrlsByPrefixSQLite(dbConnection.db, urlPrefix);
421
+ if (urls.length > 0) {
422
+ knownUrls = new Set(urls);
423
+ logger.info(`Found ${urls.length} known URLs in database for pre-seeding`);
424
+ }
425
+ }
426
+ else if (dbConnection.type === 'qdrant') {
427
+ const urls = await database_1.DatabaseManager.getStoredUrlsByPrefixQdrant(dbConnection, urlPrefix);
428
+ if (urls.length > 0) {
429
+ knownUrls = new Set(urls);
430
+ logger.info(`Found ${urls.length} known URLs in Qdrant for pre-seeding`);
431
+ }
432
+ }
433
+ // ETag store for caching page ETags across runs
434
+ const etagStore = {
435
+ get: async (url) => {
436
+ return database_1.DatabaseManager.getMetadataValue(dbConnection, `etag:${url}`, undefined, logger);
437
+ },
438
+ set: async (url, etag) => {
439
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger, this.embeddingDimension);
440
+ },
441
+ };
442
+ const lastmodStore = {
443
+ get: async (url) => {
444
+ return database_1.DatabaseManager.getMetadataValue(dbConnection, `lastmod:${url}`, undefined, logger);
445
+ },
446
+ set: async (url, lastmod) => {
447
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
448
+ },
449
+ };
398
450
  const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
399
451
  visitedUrls.add(url);
400
452
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
401
453
  try {
402
454
  const chunks = await this.contentProcessor.chunkMarkdown(content, config, url);
403
455
  logger.info(`Created ${chunks.length} chunks`);
404
- if (chunks.length > 0) {
405
- const chunkProgress = logger.progress(`Processing 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();
456
+ for (const chunk of chunks) {
457
+ validChunkIds.add(chunk.metadata.chunk_id);
468
458
  }
459
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
460
+ return true;
469
461
  }
470
462
  catch (error) {
471
463
  logger.error(`Error during chunking or embedding for ${url}:`, error);
464
+ return false;
472
465
  }
473
- }, logger, visitedUrls);
466
+ }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
474
467
  this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
475
468
  this.writeBrokenLinksReport();
476
469
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
@@ -525,7 +518,7 @@ class Doc2Vec {
525
518
  async processLocalDirectory(config, parentLogger) {
526
519
  const logger = parentLogger.child('process');
527
520
  logger.info(`Starting processing for local directory: ${config.path}`);
528
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
521
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
529
522
  const validChunkIds = new Set();
530
523
  const processedFiles = new Set();
531
524
  logger.section('FILE SCANNING AND EMBEDDING');
@@ -560,71 +553,10 @@ class Doc2Vec {
560
553
  }
561
554
  const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
562
555
  logger.info(`Created ${chunks.length} chunks`);
563
- if (chunks.length > 0) {
564
- const chunkProgress = logger.progress(`Processing 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();
556
+ for (const chunk of chunks) {
557
+ validChunkIds.add(chunk.metadata.chunk_id);
627
558
  }
559
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
628
560
  }
629
561
  catch (error) {
630
562
  logger.error(`Error during chunking or embedding for ${filePath}:`, error);
@@ -644,7 +576,7 @@ class Doc2Vec {
644
576
  async processCodeSource(config, parentLogger) {
645
577
  const logger = parentLogger.child('process');
646
578
  logger.info(`Starting processing for code source (${config.source})`);
647
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
579
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
648
580
  const validChunkIds = new Set();
649
581
  const processedFiles = new Set();
650
582
  let basePath;
@@ -728,71 +660,10 @@ class Doc2Vec {
728
660
  try {
729
661
  const chunks = await this.contentProcessor.chunkCode(content, config, fileUrl, relativePath || filePath, repoBranch || config.branch, config.repo);
730
662
  logger.info(`Created ${chunks.length} chunks`);
731
- if (chunks.length > 0) {
732
- const chunkProgress = logger.progress(`Processing 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();
663
+ for (const chunk of chunks) {
664
+ validChunkIds.add(chunk.metadata.chunk_id);
795
665
  }
666
+ await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
796
667
  }
797
668
  catch (error) {
798
669
  logger.error(`Error during code chunking or embedding for ${filePath}:`, error);
@@ -837,10 +708,10 @@ class Doc2Vec {
837
708
  await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
838
709
  }
839
710
  }
840
- await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger);
711
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
841
712
  if (lastMtimeKey) {
842
713
  const nextMtime = maxObservedMtime > 0 ? maxObservedMtime : Date.now();
843
- await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger);
714
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger, this.embeddingDimension);
844
715
  }
845
716
  }
846
717
  }
@@ -858,7 +729,7 @@ class Doc2Vec {
858
729
  const headSha = await this.getRepoHeadSha(basePath, logger);
859
730
  if (headSha) {
860
731
  const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
861
- await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger);
732
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger, this.embeddingDimension);
862
733
  }
863
734
  }
864
735
  if (tempDir) {
@@ -1021,7 +892,7 @@ class Doc2Vec {
1021
892
  async processZendesk(config, parentLogger) {
1022
893
  const logger = parentLogger.child('process');
1023
894
  logger.info(`Starting processing for Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
1024
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
895
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
1025
896
  // Initialize metadata storage
1026
897
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
1027
898
  const fetchTickets = config.fetch_tickets !== false; // default true
@@ -1196,7 +1067,7 @@ class Doc2Vec {
1196
1067
  }
1197
1068
  }
1198
1069
  // Update the last run date in the database
1199
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger);
1070
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension);
1200
1071
  logger.info(`Successfully processed ${totalTickets} tickets`);
1201
1072
  }
1202
1073
  async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
@@ -1271,61 +1142,7 @@ class Doc2Vec {
1271
1142
  };
1272
1143
  const chunks = await this.contentProcessor.chunkMarkdown(markdown, articleConfig, url);
1273
1144
  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
- }
1145
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
1329
1146
  };
1330
1147
  logger.info(`Fetching Zendesk help center articles updated since ${startDate}`);
1331
1148
  let nextPage = `${baseUrl}/articles.json`;
@@ -1356,13 +1173,98 @@ class Doc2Vec {
1356
1173
  }
1357
1174
  logger.info(`Successfully processed ${processedArticles} of ${totalArticles} articles (filtered by date >= ${startDate})`);
1358
1175
  }
1176
+ /**
1177
+ * Process chunks for a given URL with change detection.
1178
+ *
1179
+ * Compares the new chunk hashes against existing hashes stored in the DB.
1180
+ * - If all hashes match (same content, same count): skip entirely (no embedding, no DB writes).
1181
+ * - If any hash differs: delete all existing chunks for this URL and re-embed/insert all new chunks.
1182
+ *
1183
+ * This ensures chunk_index and total_chunks are always consistent, and no orphaned
1184
+ * chunks are left behind when content shifts (e.g., a paragraph is added in the middle).
1185
+ *
1186
+ * @returns The number of chunks that were embedded (0 if skipped).
1187
+ */
1188
+ async processChunksForUrl(chunks, url, dbConnection, logger) {
1189
+ if (chunks.length === 0)
1190
+ return 0;
1191
+ // 1. Compute hashes for all new chunks
1192
+ const newHashes = chunks.map(c => utils_1.Utils.generateHash(c.content));
1193
+ const newHashesSorted = newHashes.slice().sort();
1194
+ // 2. Fetch existing hashes for this URL from the DB
1195
+ let existingHashesSorted;
1196
+ if (dbConnection.type === 'sqlite') {
1197
+ existingHashesSorted = database_1.DatabaseManager.getChunkHashesByUrlSQLite(dbConnection.db, url);
1198
+ }
1199
+ else {
1200
+ existingHashesSorted = await database_1.DatabaseManager.getChunkHashesByUrlQdrant(dbConnection, url);
1201
+ }
1202
+ // 3. Compare: if identical sorted arrays, content is unchanged — skip
1203
+ const unchanged = newHashesSorted.length === existingHashesSorted.length &&
1204
+ newHashesSorted.every((h, i) => h === existingHashesSorted[i]);
1205
+ if (unchanged) {
1206
+ logger.info(`Skipping unchanged URL (${chunks.length} chunks): ${url}`);
1207
+ return 0;
1208
+ }
1209
+ // 4. Content changed — delete all existing chunks for this URL
1210
+ if (existingHashesSorted.length > 0) {
1211
+ logger.info(`Content changed for ${url} (${existingHashesSorted.length} old chunks → ${chunks.length} new chunks), re-embedding all`);
1212
+ if (dbConnection.type === 'sqlite') {
1213
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1214
+ }
1215
+ else {
1216
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1217
+ }
1218
+ }
1219
+ // 5. Embed and insert all new chunks
1220
+ let embeddedCount = 0;
1221
+ const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
1222
+ for (let i = 0; i < chunks.length; i++) {
1223
+ const chunk = chunks[i];
1224
+ const chunkHash = newHashes[i];
1225
+ const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
1226
+ const embeddings = await this.createEmbeddings([chunk.content]);
1227
+ if (embeddings.length > 0) {
1228
+ const embedding = embeddings[0];
1229
+ if (dbConnection.type === 'sqlite') {
1230
+ database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
1231
+ chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
1232
+ }
1233
+ else if (dbConnection.type === 'qdrant') {
1234
+ await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
1235
+ chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
1236
+ }
1237
+ embeddedCount++;
1238
+ }
1239
+ else {
1240
+ logger.error(`Embedding failed for chunk: ${chunkId}`);
1241
+ chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
1242
+ }
1243
+ }
1244
+ chunkProgress.complete();
1245
+ return embeddedCount;
1246
+ }
1359
1247
  async createEmbeddings(texts) {
1360
1248
  const logger = this.logger.child('embeddings');
1361
1249
  try {
1362
- logger.debug(`Creating embeddings for ${texts.length} texts`);
1250
+ // Truncate any texts that exceed the embedding model's token limit.
1251
+ // This is a safety net for pages with dense content (e.g., large API
1252
+ // reference pages with deeply nested config paths) where the chunker's
1253
+ // whitespace-based token count drastically underestimates BPE tokens.
1254
+ const safeTexts = texts.map(text => {
1255
+ if (text.length > Doc2Vec.MAX_EMBEDDING_CHARS) {
1256
+ const estimatedTokens = Math.ceil(text.length / Doc2Vec.CHARS_PER_TOKEN);
1257
+ logger.warn(`Truncating oversized chunk (${text.length} chars, ~${estimatedTokens} tokens) ` +
1258
+ `to ${Doc2Vec.MAX_EMBEDDING_CHARS} chars (~${Doc2Vec.MAX_EMBEDDING_TOKENS} tokens) ` +
1259
+ `to fit embedding model limit`);
1260
+ return text.substring(0, Doc2Vec.MAX_EMBEDDING_CHARS);
1261
+ }
1262
+ return text;
1263
+ });
1264
+ logger.debug(`Creating embeddings for ${safeTexts.length} texts`);
1363
1265
  const response = await this.openai.embeddings.create({
1364
1266
  model: this.embeddingModel,
1365
- input: texts,
1267
+ input: safeTexts,
1366
1268
  });
1367
1269
  logger.debug(`Successfully created ${response.data.length} embeddings`);
1368
1270
  return response.data.map(d => d.embedding);
@@ -1374,6 +1276,13 @@ class Doc2Vec {
1374
1276
  }
1375
1277
  }
1376
1278
  exports.Doc2Vec = Doc2Vec;
1279
+ // Embedding model token limit and character-based estimate.
1280
+ // OpenAI's text-embedding-3-large has an 8,191-token context limit.
1281
+ // Using ~4 chars per BPE token as a conservative estimate (actual average
1282
+ // is ~3.5 for English, lower for code/URLs/config paths).
1283
+ Doc2Vec.MAX_EMBEDDING_TOKENS = 8191;
1284
+ Doc2Vec.CHARS_PER_TOKEN = 4;
1285
+ Doc2Vec.MAX_EMBEDDING_CHARS = Doc2Vec.MAX_EMBEDDING_TOKENS * Doc2Vec.CHARS_PER_TOKEN;
1377
1286
  if (require.main === module) {
1378
1287
  const configPath = process.argv[2] || 'config.yaml';
1379
1288
  if (!fs.existsSync(configPath)) {
package/dist/utils.js CHANGED
@@ -69,18 +69,23 @@ class Utils {
69
69
  return url;
70
70
  }
71
71
  }
72
- static buildUrl(href, currentUrl) {
72
+ static buildUrl(href, currentUrl, logger) {
73
73
  try {
74
74
  return new URL(href, currentUrl).toString();
75
75
  }
76
76
  catch (error) {
77
- console.warn(`Invalid URL found: ${href}`);
77
+ if (logger) {
78
+ logger.warn(`Invalid URL found: ${href}`);
79
+ }
78
80
  return '';
79
81
  }
80
82
  }
81
83
  static shouldProcessUrl(url) {
82
84
  const parsedUrl = new URL(url);
83
85
  const pathname = parsedUrl.pathname;
86
+ // Paths ending with / are directory-like URLs (e.g., /app/2.1.x/), always process them
87
+ if (pathname.endsWith('/'))
88
+ return true;
84
89
  const ext = path.extname(pathname);
85
90
  if (!ext)
86
91
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",