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/README.md +68 -8
- package/dist/content-processor.js +682 -109
- package/dist/database.js +95 -5
- package/dist/doc2vec.js +143 -250
- package/dist/utils.js +3 -0
- package/package.json +1 -1
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 ??
|
|
377
|
+
insertValues.push(chunk.metadata.branch ?? '');
|
|
378
378
|
}
|
|
379
379
|
if (hasRepoColumn) {
|
|
380
|
-
insertValues.push(chunk.metadata.repo ??
|
|
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 ??
|
|
392
|
+
updateValues.push(chunk.metadata.branch ?? '');
|
|
393
393
|
}
|
|
394
394
|
if (hasRepoColumn) {
|
|
395
|
-
updateValues.push(chunk.metadata.repo ??
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|