doc2vec 1.1.1 → 1.2.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/doc2vec.ts DELETED
@@ -1,1734 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { Readability } from '@mozilla/readability';
4
- import axios from 'axios';
5
- import { load } from 'cheerio';
6
- import crypto from 'crypto';
7
- import { JSDOM } from 'jsdom';
8
- import puppeteer, { Browser, Page } from 'puppeteer';
9
- import sanitizeHtml from 'sanitize-html';
10
- import TurndownService from 'turndown';
11
- import * as yaml from 'js-yaml';
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import BetterSqlite3, { Database } from "better-sqlite3";
15
- import { OpenAI } from "openai";
16
- import * as dotenv from "dotenv";
17
- import * as sqliteVec from "sqlite-vec";
18
- import { QdrantClient } from '@qdrant/js-client-rest';
19
- import { Logger, LogLevel } from './logger';
20
-
21
- const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
22
-
23
- dotenv.config();
24
-
25
- interface Config {
26
- sources: SourceConfig[];
27
- }
28
-
29
- // Base configuration that applies to all source types
30
- interface BaseSourceConfig {
31
- type: 'website' | 'github' | 'local_directory';
32
- product_name: string;
33
- version: string;
34
- max_size: number;
35
- database_config: DatabaseConfig;
36
- }
37
-
38
- // Configuration specific to local directory sources
39
- interface LocalDirectorySourceConfig extends BaseSourceConfig {
40
- type: 'local_directory';
41
- path: string; // Path to the local directory
42
- include_extensions?: string[]; // File extensions to include (e.g., ['.md', '.txt'])
43
- exclude_extensions?: string[]; // File extensions to exclude
44
- recursive?: boolean; // Whether to traverse subdirectories
45
- encoding?: BufferEncoding; // File encoding (default: 'utf8')
46
- url_rewrite_prefix?: string; // Optional URL prefix to rewrite file:// URLs (e.g., 'https://mydomain.com')
47
- }
48
-
49
- // Configuration specific to website sources
50
- interface WebsiteSourceConfig extends BaseSourceConfig {
51
- type: 'website';
52
- url: string;
53
- sitemap_url?: string; // Optional sitemap URL to extract additional URLs to crawl
54
- }
55
-
56
- // Configuration specific to GitHub repo sources
57
- interface GithubSourceConfig extends BaseSourceConfig {
58
- type: 'github';
59
- repo: string;
60
- start_date?: string;
61
- }
62
-
63
- // Union type for all possible source configurations
64
- type SourceConfig = WebsiteSourceConfig | GithubSourceConfig | LocalDirectorySourceConfig;
65
-
66
- // Database configuration
67
- interface DatabaseConfig {
68
- type: 'sqlite' | 'qdrant';
69
- params: SqliteDatabaseParams | QdrantDatabaseParams;
70
- }
71
-
72
- interface SqliteDatabaseParams {
73
- db_path?: string; // Optional, will use default if not provided
74
- }
75
-
76
- interface QdrantDatabaseParams {
77
- qdrant_url?: string;
78
- qdrant_port?: number;
79
- collection_name?: string;
80
- }
81
-
82
- interface DocumentChunk {
83
- content: string;
84
- metadata: {
85
- product_name: string;
86
- version: string;
87
- heading_hierarchy: string[];
88
- section: string;
89
- chunk_id: string;
90
- url: string;
91
- hash?: string;
92
- };
93
- }
94
-
95
- interface SqliteDB {
96
- db: Database;
97
- type: 'sqlite';
98
- }
99
-
100
- interface QdrantDB {
101
- client: QdrantClient;
102
- collectionName: string;
103
- type: 'qdrant';
104
- }
105
-
106
- type DatabaseConnection = SqliteDB | QdrantDB;
107
-
108
- class Doc2Vec {
109
- private config: Config;
110
- private openai: OpenAI;
111
- private turndownService: TurndownService;
112
- private logger: Logger;
113
-
114
- constructor(configPath: string) {
115
- this.logger = new Logger('Doc2Vec', {
116
- level: LogLevel.DEBUG,
117
- useTimestamp: true,
118
- useColor: true,
119
- prettyPrint: true
120
- });
121
-
122
- this.logger.info('Initializing Doc2Vec');
123
- this.config = this.loadConfig(configPath);
124
- this.openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
125
-
126
- this.turndownService = new TurndownService({
127
- codeBlockStyle: 'fenced',
128
- headingStyle: 'atx'
129
- });
130
- this.setupTurndownRules();
131
- }
132
-
133
- private loadConfig(configPath: string): Config {
134
- try {
135
- const logger = this.logger.child('config');
136
- logger.info(`Loading configuration from ${configPath}`);
137
-
138
- const configFile = fs.readFileSync(configPath, 'utf8');
139
- let config = yaml.load(configFile) as any;
140
-
141
- const typedConfig = config as Config;
142
- logger.info(`Configuration loaded successfully, found ${typedConfig.sources.length} sources`);
143
- return typedConfig;
144
- } catch (error) {
145
- this.logger.error(`Failed to load or parse config file at ${configPath}:`, error);
146
- process.exit(1);
147
- }
148
- }
149
-
150
- private setupTurndownRules() {
151
- const logger = this.logger.child('markdown');
152
- logger.debug('Setting up Turndown rules for markdown conversion');
153
-
154
- this.turndownService.addRule('codeBlocks', {
155
- filter: (node: Node): boolean => node.nodeName === 'PRE',
156
- replacement: (content: string, node: Node): string => {
157
- const htmlNode = node as HTMLElement;
158
- const code = htmlNode.querySelector('code');
159
-
160
- let codeContent;
161
- if (code) {
162
- codeContent = code.textContent || '';
163
- } else {
164
- codeContent = htmlNode.textContent || '';
165
- }
166
-
167
- const lines = codeContent.split('\n');
168
- let minIndent = Infinity;
169
- for (const line of lines) {
170
- if (line.trim() === '') continue;
171
- const leadingWhitespace = line.match(/^\s*/)?.[0] || '';
172
- minIndent = Math.min(minIndent, leadingWhitespace.length);
173
- }
174
-
175
- const cleanedLines = lines.map(line => {
176
- return line.substring(minIndent);
177
- });
178
-
179
- let cleanContent = cleanedLines.join('\n');
180
- cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
181
- cleanContent = cleanContent.replace(/\n{2,}/g, '\n');
182
-
183
- return `\n\`\`\`\n${cleanContent}\n\`\`\`\n`;
184
- }
185
- });
186
-
187
- this.turndownService.addRule('tableCell', {
188
- filter: ['th', 'td'],
189
- replacement: (content: string, node: Node): string => {
190
- const htmlNode = node as HTMLElement;
191
-
192
- let cellContent = '';
193
- if (htmlNode.querySelector('p')) {
194
- cellContent = Array.from(htmlNode.querySelectorAll('p'))
195
- .map(p => p.textContent || '')
196
- .join(' ')
197
- .trim();
198
- } else {
199
- cellContent = content.trim();
200
- }
201
-
202
- return ` ${cellContent.replace(/\|/g, '\\|')} |`;
203
- }
204
- });
205
-
206
- this.turndownService.addRule('tableRow', {
207
- filter: 'tr',
208
- replacement: (content: string, node: Node): string => {
209
- const htmlNode = node as HTMLTableRowElement;
210
- const cells = Array.from(htmlNode.cells);
211
- const isHeader = htmlNode.parentNode?.nodeName === 'THEAD';
212
-
213
- let output = '|' + content.trimEnd();
214
-
215
- if (isHeader) {
216
- const separator = cells.map(() => '---').join(' | ');
217
- output += '\n|' + separator + '|';
218
- }
219
-
220
- if (!isHeader || !htmlNode.nextElementSibling) {
221
- output += '\n';
222
- }
223
-
224
- return output;
225
- }
226
- });
227
-
228
- this.turndownService.addRule('table', {
229
- filter: 'table',
230
- replacement: (content: string): string => {
231
- return '\n' + content.replace(/\n+/g, '\n').trim() + '\n';
232
- }
233
- });
234
-
235
- this.turndownService.addRule('preserveTableWhitespace', {
236
- filter: (node: Node): boolean => {
237
- return (
238
- (node.nodeName === 'TD' || node.nodeName === 'TH') &&
239
- (node.textContent?.trim().length === 0)
240
- );
241
- },
242
- replacement: (): string => {
243
- return ' |';
244
- }
245
- });
246
-
247
- logger.debug('Turndown rules setup complete');
248
- }
249
-
250
- public async run(): Promise<void> {
251
- this.logger.section('PROCESSING SOURCES');
252
-
253
- for (const sourceConfig of this.config.sources) {
254
- const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
255
-
256
- sourceLogger.info(`Processing ${sourceConfig.type} source for ${sourceConfig.product_name}@${sourceConfig.version}`);
257
-
258
- if (sourceConfig.type === 'github') {
259
- await this.processGithubRepo(sourceConfig, sourceLogger);
260
- } else if (sourceConfig.type === 'website') {
261
- await this.processWebsite(sourceConfig, sourceLogger);
262
- } else if (sourceConfig.type === 'local_directory') {
263
- await this.processLocalDirectory(sourceConfig, sourceLogger);
264
- } else {
265
- sourceLogger.error(`Unknown source type: ${(sourceConfig as any).type}`);
266
- }
267
- }
268
-
269
- this.logger.section('PROCESSING COMPLETE');
270
- }
271
-
272
- private getUrlPrefix(url: string): string {
273
- try {
274
- const parsedUrl = new URL(url);
275
- return parsedUrl.origin + parsedUrl.pathname;
276
- } catch (error) {
277
- return url;
278
- }
279
- }
280
-
281
- private generateMetadataUUID(repo: string): string {
282
- // Simple deterministic approach - hash the repo name and convert to UUID format
283
- const hash = crypto.createHash('md5').update(`metadata_${repo}`).digest('hex');
284
- // Format as UUID with version bits set correctly (version 4)
285
- return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-4${hash.substr(13, 3)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
286
- }
287
-
288
- private async initDatabaseMetadata(dbConnection: DatabaseConnection): Promise<void> {
289
- const logger = this.logger.child('metadata');
290
-
291
- if (dbConnection.type === 'sqlite') {
292
- const db = dbConnection.db;
293
- logger.debug('Creating metadata table if it doesn\'t exist');
294
- db.exec(`
295
- CREATE TABLE IF NOT EXISTS vec_metadata (
296
- key TEXT PRIMARY KEY,
297
- value TEXT
298
- );
299
- `);
300
- logger.info('SQLite metadata table initialized');
301
- } else if (dbConnection.type === 'qdrant') {
302
- // For Qdrant, we'll use the same collection but verify it exists
303
- logger.info(`Using existing Qdrant collection for metadata: ${dbConnection.collectionName}`);
304
- // Nothing special to initialize as we'll use the same collection
305
- }
306
- }
307
-
308
- private async getLastRunDate(dbConnection: DatabaseConnection, repo: string, defaultDate: string): Promise<string> {
309
- const logger = this.logger.child('metadata');
310
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
311
-
312
- try {
313
- if (dbConnection.type === 'sqlite') {
314
- const stmt = dbConnection.db.prepare('SELECT value FROM vec_metadata WHERE key = ?');
315
- const result = stmt.get(metadataKey) as { value: string } | undefined;
316
-
317
- if (result) {
318
- logger.info(`Retrieved last run date for ${repo}: ${result.value}`);
319
- return result.value;
320
- }
321
- } else if (dbConnection.type === 'qdrant') {
322
- // Generate a UUID for this repo's metadata
323
- const metadataUUID = this.generateMetadataUUID(repo);
324
- logger.debug(`Looking up metadata with UUID: ${metadataUUID}`);
325
-
326
- try {
327
- // Try to retrieve the metadata point for this repo
328
- const response = await dbConnection.client.retrieve(dbConnection.collectionName, {
329
- ids: [metadataUUID],
330
- with_payload: true,
331
- with_vector: false
332
- });
333
-
334
- if (response.length > 0 && response[0].payload?.metadata_value) {
335
- const lastRunDate = response[0].payload.metadata_value as string;
336
- logger.info(`Retrieved last run date for ${repo}: ${lastRunDate}`);
337
- return lastRunDate;
338
- }
339
- } catch (error) {
340
- logger.warn(`Failed to retrieve metadata for ${repo}:`, error);
341
- }
342
- }
343
- } catch (error) {
344
- logger.warn(`Error retrieving last run date:`, error);
345
- }
346
-
347
- logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
348
- return defaultDate;
349
- }
350
-
351
- private async updateLastRunDate(dbConnection: DatabaseConnection, repo: string): Promise<void> {
352
- const logger = this.logger.child('metadata');
353
- const now = new Date().toISOString();
354
-
355
- try {
356
- if (dbConnection.type === 'sqlite') {
357
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
358
- const stmt = dbConnection.db.prepare(`
359
- INSERT INTO vec_metadata (key, value) VALUES (?, ?)
360
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
361
- `);
362
- stmt.run(metadataKey, now);
363
- logger.info(`Updated last run date for ${repo} to ${now}`);
364
- } else if (dbConnection.type === 'qdrant') {
365
- // Generate UUID for this repo's metadata
366
- const metadataUUID = this.generateMetadataUUID(repo);
367
- const metadataKey = `last_run_${repo.replace('/', '_')}`;
368
-
369
- logger.debug(`Using UUID: ${metadataUUID} for metadata`);
370
-
371
- // Generate a dummy embedding (all zeros)
372
- const dummyEmbeddingSize = 3072; // Same size as your content embeddings
373
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
374
-
375
- // Create a point with special metadata payload
376
- const metadataPoint = {
377
- id: metadataUUID,
378
- vector: dummyEmbedding,
379
- payload: {
380
- metadata_key: metadataKey,
381
- metadata_value: now,
382
- is_metadata: true, // Flag to identify metadata points
383
- content: `Metadata: Last run date for ${repo}`,
384
- product_name: 'system',
385
- version: 'metadata',
386
- url: 'metadata://' + repo
387
- }
388
- };
389
-
390
- await dbConnection.client.upsert(dbConnection.collectionName, {
391
- wait: true,
392
- points: [metadataPoint]
393
- });
394
-
395
- logger.info(`Updated last run date for ${repo} to ${now}`);
396
- }
397
- } catch (error) {
398
- logger.error(`Failed to update last run date for ${repo}:`, error);
399
- }
400
- }
401
-
402
- private async fetchAndProcessGitHubIssues(repo: string, sourceConfig: GithubSourceConfig, dbConnection: DatabaseConnection, logger: Logger): Promise<void> {
403
- const [owner, repoName] = repo.split('/');
404
- const GITHUB_API_URL = `https://api.github.com/repos/${owner}/${repoName}/issues`;
405
-
406
- // Initialize metadata storage if needed
407
- await this.initDatabaseMetadata(dbConnection);
408
-
409
- // Get the last run date from the database
410
- const startDate = sourceConfig.start_date || '2025-01-01';
411
- const lastRunDate = await this.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`);
412
-
413
- const fetchWithRetry = async (url: string, params = {}, retries = 5, delay = 5000): Promise<any> => {
414
- for (let attempt = 0; attempt < retries; attempt++) {
415
- try {
416
- const response = await axios.get(url, {
417
- headers: {
418
- Authorization: `token ${GITHUB_TOKEN}`,
419
- Accept: 'application/vnd.github.v3+json',
420
- },
421
- params,
422
- });
423
- return response.data;
424
- } catch (error: any) {
425
- if (error.response && error.response.status === 403) {
426
- const resetTime = error.response.headers['x-ratelimit-reset'];
427
- const currentTime = Math.floor(Date.now() / 1000);
428
- const waitTime = resetTime ? (resetTime - currentTime) * 1000 : delay * 2;
429
- logger.warn(`GitHub rate limit exceeded. Waiting ${waitTime / 1000}s`);
430
- await new Promise(res => setTimeout(res, waitTime));
431
- } else {
432
- logger.error(`GitHub fetch failed: ${error.message}`);
433
- throw error;
434
- }
435
- }
436
- }
437
- throw new Error('Max retries reached');
438
- };
439
-
440
- const fetchAllIssues = async (sinceDate: string): Promise<any[]> => {
441
- let issues: any[] = [];
442
- let page = 1;
443
- const perPage = 100;
444
- const sinceTimestamp = new Date(sinceDate);
445
-
446
- while (true) {
447
- const data = await fetchWithRetry(GITHUB_API_URL, {
448
- per_page: perPage,
449
- page,
450
- state: 'all',
451
- since: sinceDate,
452
- });
453
-
454
- if (data.length === 0) break;
455
-
456
- const filtered = data.filter((issue: any) => new Date(issue.created_at) >= sinceTimestamp);
457
- issues = issues.concat(filtered);
458
-
459
- if (filtered.length < data.length) break;
460
- page++;
461
- }
462
- return issues;
463
- };
464
-
465
- const fetchIssueComments = async (issueNumber: number): Promise<any[]> => {
466
- const url = `${GITHUB_API_URL}/${issueNumber}/comments`;
467
- return await fetchWithRetry(url);
468
- };
469
-
470
- const generateMarkdownForIssue = async (issue: any): Promise<string> => {
471
- const comments = await fetchIssueComments(issue.number);
472
- let md = `# Issue #${issue.number}: ${issue.title}\n\n`;
473
- md += `- **Author:** ${issue.user.login}\n`;
474
- md += `- **State:** ${issue.state}\n`;
475
- md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
476
- md += `- **Updated on:** ${new Date(issue.updated_at).toDateString()}\n`;
477
- md += `- **Labels:** ${issue.labels.map((l: any) => `\`${l.name}\``).join(', ') || 'None'}\n\n`;
478
- md += `## Description\n\n${issue.body || '_No description._'}\n\n## Comments\n\n`;
479
-
480
- if (comments.length === 0) {
481
- md += '_No comments._\n';
482
- } else {
483
- for (const c of comments) {
484
- md += `### ${c.user.login} - ${new Date(c.created_at).toDateString()}\n\n${c.body}\n\n---\n\n`;
485
- }
486
- }
487
-
488
- return md;
489
- };
490
-
491
- // Process a single issue and store its chunks
492
- const processIssue = async (issue: any): Promise<void> => {
493
- const issueNumber = issue.number;
494
- const url = `https://github.com/${repo}/issues/${issueNumber}`;
495
-
496
- logger.info(`Processing issue #${issueNumber}`);
497
-
498
- // Generate markdown for the issue
499
- const markdown = await generateMarkdownForIssue(issue);
500
-
501
- // Chunk the markdown content
502
- const issueConfig = {
503
- ...sourceConfig,
504
- product_name: sourceConfig.product_name || repo,
505
- max_size: sourceConfig.max_size || Infinity
506
- };
507
-
508
- const chunks = await this.chunkMarkdown(markdown, issueConfig, url);
509
- logger.info(`Issue #${issueNumber}: Created ${chunks.length} chunks`);
510
-
511
- // Process and store each chunk immediately
512
- for (const chunk of chunks) {
513
- const chunkHash = this.generateHash(chunk.content);
514
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
515
-
516
- if (dbConnection.type === 'sqlite') {
517
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
518
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
519
-
520
- if (existing && existing.hash === chunkHash) {
521
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
522
- continue;
523
- }
524
-
525
- const embeddings = await this.createEmbeddings([chunk.content]);
526
- if (embeddings.length) {
527
- this.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
528
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
529
- } else {
530
- logger.error(`Embedding failed for chunk: ${chunkId}`);
531
- }
532
- } else if (dbConnection.type === 'qdrant') {
533
- try {
534
- let pointId: string;
535
- try {
536
- pointId = chunk.metadata.chunk_id;
537
- if (!this.isValidUuid(pointId)) {
538
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
539
- }
540
- } catch (e) {
541
- pointId = crypto.randomUUID();
542
- }
543
-
544
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
545
- ids: [pointId],
546
- with_payload: true,
547
- with_vector: false,
548
- });
549
-
550
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
551
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
552
- continue;
553
- }
554
-
555
- const embeddings = await this.createEmbeddings([chunk.content]);
556
- if (embeddings.length) {
557
- await this.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
558
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
559
- } else {
560
- logger.error(`Embedding failed for chunk: ${chunkId}`);
561
- }
562
- } catch (error) {
563
- logger.error(`Error processing chunk in Qdrant:`, error);
564
- }
565
- }
566
- }
567
- };
568
-
569
- logger.info(`Fetching GitHub issues for ${repo} since ${lastRunDate}`);
570
- const issues = await fetchAllIssues(lastRunDate);
571
- logger.info(`Found ${issues.length} updated/new issues`);
572
-
573
- // Process each issue individually, one at a time
574
- for (let i = 0; i < issues.length; i++) {
575
- logger.info(`Processing issue ${i + 1}/${issues.length}`);
576
- await processIssue(issues[i]);
577
- }
578
-
579
- // Update the last run date in the database after processing all issues
580
- await this.updateLastRunDate(dbConnection, repo);
581
-
582
- logger.info(`Successfully processed ${issues.length} issues`);
583
- }
584
-
585
- private async processGithubRepo(config: GithubSourceConfig, parentLogger: Logger): Promise<void> {
586
- const logger = parentLogger.child('process');
587
- logger.info(`Starting processing for GitHub repo: ${config.repo}`);
588
-
589
- const dbConnection = await this.initDatabase(config, logger);
590
-
591
- // Initialize metadata storage
592
- await this.initDatabaseMetadata(dbConnection);
593
-
594
- logger.section('GITHUB ISSUES');
595
-
596
- // Process GitHub issues
597
- await this.fetchAndProcessGitHubIssues(config.repo, config, dbConnection, logger);
598
-
599
- logger.info(`Finished processing GitHub repo: ${config.repo}`);
600
- }
601
-
602
- private async processWebsite(config: WebsiteSourceConfig, parentLogger: Logger): Promise<void> {
603
- const logger = parentLogger.child('process');
604
- logger.info(`Starting processing for website: ${config.url}`);
605
-
606
- const dbConnection = await this.initDatabase(config, logger);
607
- const validChunkIds: Set<string> = new Set();
608
- const visitedUrls: Set<string> = new Set();
609
- const urlPrefix = this.getUrlPrefix(config.url);
610
-
611
- logger.section('CRAWL AND EMBEDDING');
612
-
613
- await this.crawlWebsite(config.url, config, async (url, content) => {
614
- visitedUrls.add(url);
615
-
616
- logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
617
- try {
618
- const chunks = await this.chunkMarkdown(content, config, url);
619
- logger.info(`Created ${chunks.length} chunks`);
620
-
621
- if (chunks.length > 0) {
622
- const chunkProgress = logger.progress(`Embedding chunks for ${url}`, chunks.length);
623
-
624
- for (let i = 0; i < chunks.length; i++) {
625
- const chunk = chunks[i];
626
- validChunkIds.add(chunk.metadata.chunk_id);
627
-
628
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
629
-
630
- let needsEmbedding = true;
631
- const chunkHash = this.generateHash(chunk.content);
632
-
633
- if (dbConnection.type === 'sqlite') {
634
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
635
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
636
-
637
- if (existing && existing.hash === chunkHash) {
638
- needsEmbedding = false;
639
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
640
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
641
- }
642
- } else if (dbConnection.type === 'qdrant') {
643
- try {
644
- let pointId: string;
645
- try {
646
- pointId = chunk.metadata.chunk_id;
647
- if (!this.isValidUuid(pointId)) {
648
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
649
- }
650
- } catch (e) {
651
- pointId = crypto.randomUUID();
652
- }
653
-
654
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
655
- ids: [pointId],
656
- with_payload: true,
657
- with_vector: false,
658
- });
659
-
660
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
661
- needsEmbedding = false;
662
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
663
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
664
- }
665
- } catch (error) {
666
- logger.error(`Error checking existing point in Qdrant:`, error);
667
- }
668
- }
669
-
670
-
671
- if (needsEmbedding) {
672
- const embeddings = await this.createEmbeddings([chunk.content]);
673
- if (embeddings.length > 0) {
674
- const embedding = embeddings[0];
675
- if (dbConnection.type === 'sqlite') {
676
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
677
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
678
- } else if (dbConnection.type === 'qdrant') {
679
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
680
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
681
- }
682
- } else {
683
- logger.error(`Embedding failed for chunk: ${chunkId}`);
684
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
685
- }
686
- }
687
- }
688
-
689
- chunkProgress.complete();
690
- }
691
-
692
- } catch (error) {
693
- logger.error(`Error during chunking or embedding for ${url}:`, error);
694
- }
695
-
696
- }, logger, visitedUrls);
697
-
698
- logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
699
-
700
- logger.section('CLEANUP');
701
- if (dbConnection.type === 'sqlite') {
702
- logger.info(`Running SQLite cleanup for ${urlPrefix}`);
703
- this.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
704
- } else if (dbConnection.type === 'qdrant') {
705
- logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
706
- await this.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
707
- }
708
-
709
- logger.info(`Finished processing website: ${config.url}`);
710
- }
711
-
712
- private async processLocalDirectory(config: LocalDirectorySourceConfig, parentLogger: Logger): Promise<void> {
713
- const logger = parentLogger.child('process');
714
- logger.info(`Starting processing for local directory: ${config.path}`);
715
-
716
- const dbConnection = await this.initDatabase(config, logger);
717
- const validChunkIds: Set<string> = new Set();
718
- const processedFiles: Set<string> = new Set();
719
-
720
- logger.section('FILE SCANNING AND EMBEDDING');
721
-
722
- await this.processDirectory(
723
- config.path,
724
- config,
725
- async (filePath, content) => {
726
- processedFiles.add(filePath);
727
-
728
- logger.info(`Processing content from ${filePath} (${content.length} chars)`);
729
- try {
730
- // Generate URL based on configuration
731
- let fileUrl: string;
732
-
733
- if (config.url_rewrite_prefix) {
734
- // Replace local path with URL prefix
735
- const relativePath = path.relative(config.path, filePath).replace(/\\/g, '/');
736
-
737
- // If relativePath starts with '..', it means the file is outside the base directory
738
- if (relativePath.startsWith('..')) {
739
- // For files outside the configured path, use the default file:// scheme
740
- fileUrl = `file://${filePath}`;
741
- logger.debug(`File outside configured path, using default URL: ${fileUrl}`);
742
- } else {
743
- // For files inside the configured path, rewrite the URL
744
- // Handle trailing slashes in the URL prefix to avoid double slashes
745
- const prefix = config.url_rewrite_prefix.endsWith('/')
746
- ? config.url_rewrite_prefix.slice(0, -1)
747
- : config.url_rewrite_prefix;
748
-
749
- fileUrl = `${prefix}/${relativePath}`;
750
- logger.debug(`URL rewritten: ${filePath} -> ${fileUrl}`);
751
- }
752
- } else {
753
- // Use default file:// URL
754
- fileUrl = `file://${filePath}`;
755
- }
756
-
757
- const chunks = await this.chunkMarkdown(content, config, fileUrl);
758
- logger.info(`Created ${chunks.length} chunks`);
759
-
760
- if (chunks.length > 0) {
761
- const chunkProgress = logger.progress(`Embedding chunks for ${filePath}`, chunks.length);
762
-
763
- for (let i = 0; i < chunks.length; i++) {
764
- const chunk = chunks[i];
765
- validChunkIds.add(chunk.metadata.chunk_id);
766
-
767
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
768
-
769
- let needsEmbedding = true;
770
- const chunkHash = this.generateHash(chunk.content);
771
-
772
- if (dbConnection.type === 'sqlite') {
773
- const { checkHashStmt } = this.prepareSQLiteStatements(dbConnection.db);
774
- const existing = checkHashStmt.get(chunk.metadata.chunk_id) as { hash: string } | undefined;
775
-
776
- if (existing && existing.hash === chunkHash) {
777
- needsEmbedding = false;
778
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
779
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
780
- }
781
- } else if (dbConnection.type === 'qdrant') {
782
- try {
783
- let pointId: string;
784
- try {
785
- pointId = chunk.metadata.chunk_id;
786
- if (!this.isValidUuid(pointId)) {
787
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
788
- }
789
- } catch (e) {
790
- pointId = crypto.randomUUID();
791
- }
792
-
793
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
794
- ids: [pointId],
795
- with_payload: true,
796
- with_vector: false,
797
- });
798
-
799
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
800
- needsEmbedding = false;
801
- chunkProgress.update(1, `Skipping unchanged chunk ${chunkId}`);
802
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
803
- }
804
- } catch (error) {
805
- logger.error(`Error checking existing point in Qdrant:`, error);
806
- }
807
- }
808
-
809
- if (needsEmbedding) {
810
- const embeddings = await this.createEmbeddings([chunk.content]);
811
- if (embeddings.length > 0) {
812
- const embedding = embeddings[0];
813
- if (dbConnection.type === 'sqlite') {
814
- this.insertVectorsSQLite(dbConnection.db, chunk, embedding, logger, chunkHash);
815
- chunkProgress.update(1, `Stored chunk ${chunkId} in SQLite`);
816
- } else if (dbConnection.type === 'qdrant') {
817
- await this.storeChunkInQdrant(dbConnection, chunk, embedding, chunkHash);
818
- chunkProgress.update(1, `Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
819
- }
820
- } else {
821
- logger.error(`Embedding failed for chunk: ${chunkId}`);
822
- chunkProgress.update(1, `Failed to embed chunk ${chunkId}`);
823
- }
824
- }
825
- }
826
-
827
- chunkProgress.complete();
828
- }
829
- } catch (error) {
830
- logger.error(`Error during chunking or embedding for ${filePath}:`, error);
831
- }
832
- },
833
- logger
834
- );
835
-
836
- logger.section('CLEANUP');
837
- if (dbConnection.type === 'sqlite') {
838
- logger.info(`Running SQLite cleanup for local directory ${config.path}`);
839
- this.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
840
- } else if (dbConnection.type === 'qdrant') {
841
- logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
842
- await this.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
843
- }
844
-
845
- logger.info(`Finished processing local directory: ${config.path}`);
846
- }
847
-
848
- private async processDirectory(
849
- dirPath: string,
850
- config: LocalDirectorySourceConfig,
851
- processFileContent: (filePath: string, content: string) => Promise<void>,
852
- parentLogger: Logger,
853
- visitedPaths: Set<string> = new Set()
854
- ): Promise<void> {
855
- const logger = parentLogger.child('directory-processor');
856
- logger.info(`Processing directory: ${dirPath}`);
857
-
858
- const recursive = config.recursive !== undefined ? config.recursive : true;
859
- const includeExtensions = config.include_extensions || ['.md', '.txt', '.html', '.htm'];
860
- const excludeExtensions = config.exclude_extensions || [];
861
- const encoding = config.encoding || 'utf8' as BufferEncoding;
862
-
863
- try {
864
- const files = fs.readdirSync(dirPath);
865
- let processedFiles = 0;
866
- let skippedFiles = 0;
867
-
868
- for (const file of files) {
869
- const filePath = path.join(dirPath, file);
870
- const stat = fs.statSync(filePath);
871
-
872
- // Skip already visited paths
873
- if (visitedPaths.has(filePath)) {
874
- logger.debug(`Skipping already visited path: ${filePath}`);
875
- continue;
876
- }
877
-
878
- visitedPaths.add(filePath);
879
-
880
- if (stat.isDirectory()) {
881
- if (recursive) {
882
- await this.processDirectory(filePath, config, processFileContent, logger, visitedPaths);
883
- } else {
884
- logger.debug(`Skipping directory ${filePath} (recursive=false)`);
885
- }
886
- } else if (stat.isFile()) {
887
- const extension = path.extname(file).toLowerCase();
888
-
889
- // Apply extension filters
890
- if (excludeExtensions.includes(extension)) {
891
- logger.debug(`Skipping file with excluded extension: ${filePath}`);
892
- skippedFiles++;
893
- continue;
894
- }
895
-
896
- if (includeExtensions.length > 0 && !includeExtensions.includes(extension)) {
897
- logger.debug(`Skipping file with non-included extension: ${filePath}`);
898
- skippedFiles++;
899
- continue;
900
- }
901
-
902
- try {
903
- logger.info(`Reading file: ${filePath}`);
904
- const content = fs.readFileSync(filePath, { encoding: encoding as BufferEncoding });
905
-
906
- if (content.length > config.max_size) {
907
- logger.warn(`File content (${content.length} chars) exceeds max size (${config.max_size}). Skipping ${filePath}.`);
908
- skippedFiles++;
909
- continue;
910
- }
911
-
912
- // Convert HTML to Markdown if needed
913
- let processedContent: string;
914
- if (extension === '.html' || extension === '.htm') {
915
- logger.debug(`Converting HTML to Markdown for ${filePath}`);
916
- const cleanHtml = sanitizeHtml(content, {
917
- allowedTags: [
918
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
919
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
920
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
921
- ],
922
- allowedAttributes: {
923
- 'a': ['href'],
924
- 'pre': ['class', 'data-language'],
925
- 'code': ['class', 'data-language'],
926
- 'div': ['class'],
927
- 'span': ['class']
928
- }
929
- });
930
- processedContent = this.turndownService.turndown(cleanHtml);
931
- } else {
932
- processedContent = content;
933
- }
934
-
935
- await processFileContent(filePath, processedContent);
936
- processedFiles++;
937
- } catch (error) {
938
- logger.error(`Error processing file ${filePath}:`, error);
939
- }
940
- }
941
- }
942
-
943
- logger.info(`Directory processed. Processed: ${processedFiles}, Skipped: ${skippedFiles}`);
944
- } catch (error) {
945
- logger.error(`Error reading directory ${dirPath}:`, error);
946
- }
947
- }
948
-
949
- private removeObsoleteFilesSQLite(
950
- db: Database,
951
- processedFiles: Set<string>,
952
- pathConfig: { path: string; url_rewrite_prefix?: string } | string,
953
- logger: Logger
954
- ) {
955
- const getChunksForPathStmt = db.prepare(`
956
- SELECT chunk_id, url FROM vec_items
957
- WHERE url LIKE ? || '%'
958
- `);
959
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
960
-
961
- // Determine if we're using URL rewriting or direct file paths
962
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
963
-
964
- // Set up the URL prefix for searching
965
- let urlPrefix: string;
966
- if (isRewriteMode) {
967
- // Handle URL rewriting case
968
- urlPrefix = (pathConfig as { path: string; url_rewrite_prefix?: string }).url_rewrite_prefix || '';
969
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
970
- } else {
971
- // Handle direct file path case
972
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
973
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
974
- urlPrefix = `file://${cleanedDirPrefix}`;
975
- }
976
-
977
- logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
978
- const existingChunks = getChunksForPathStmt.all(urlPrefix) as { chunk_id: string; url: string }[];
979
- let deletedCount = 0;
980
-
981
- const transaction = db.transaction(() => {
982
- for (const { chunk_id, url } of existingChunks) {
983
- // Skip if it's not from our URL prefix (safety check)
984
- if (!url.startsWith(urlPrefix)) continue;
985
-
986
- let filePath: string;
987
- let shouldDelete = false;
988
-
989
- if (isRewriteMode) {
990
- // URL rewrite mode: extract relative path and construct full file path
991
- const config = pathConfig as { path: string; url_rewrite_prefix?: string };
992
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
993
- filePath = path.join(config.path, relativePath);
994
- shouldDelete = !processedFiles.has(filePath);
995
- } else {
996
- // Direct file path mode: remove file:// prefix to match with processedFiles
997
- filePath = url.substring(7); // Remove 'file://' prefix
998
- shouldDelete = !processedFiles.has(filePath);
999
- }
1000
-
1001
- if (shouldDelete) {
1002
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
1003
- deleteChunkStmt.run(chunk_id);
1004
- deletedCount++;
1005
- }
1006
- }
1007
- });
1008
- transaction();
1009
-
1010
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
1011
- }
1012
-
1013
- private async removeObsoleteFilesQdrant(
1014
- db: QdrantDB,
1015
- processedFiles: Set<string>,
1016
- pathConfig: { path: string; url_rewrite_prefix?: string } | string,
1017
- logger: Logger
1018
- ) {
1019
- const { client, collectionName } = db;
1020
- try {
1021
- // Determine if we're using URL rewriting or direct file paths
1022
- const isRewriteMode = typeof pathConfig === 'object' && pathConfig.url_rewrite_prefix;
1023
-
1024
- // Set up the URL prefix for searching
1025
- let urlPrefix: string;
1026
- if (isRewriteMode) {
1027
- // Handle URL rewriting case
1028
- urlPrefix = (pathConfig as { path: string; url_rewrite_prefix?: string }).url_rewrite_prefix || '';
1029
- urlPrefix = urlPrefix.endsWith('/') ? urlPrefix.slice(0, -1) : urlPrefix;
1030
- } else {
1031
- // Handle direct file path case
1032
- const dirPrefix = typeof pathConfig === 'string' ? pathConfig : pathConfig.path;
1033
- const cleanedDirPrefix = dirPrefix.replace(/^\.\/+/, '');
1034
- urlPrefix = `file://${cleanedDirPrefix}`;
1035
- }
1036
-
1037
- logger.debug(`Checking for obsolete chunks with URL prefix: ${urlPrefix}`);
1038
- const response = await client.scroll(collectionName, {
1039
- limit: 10000,
1040
- with_payload: true,
1041
- with_vector: false,
1042
- filter: {
1043
- must: [
1044
- {
1045
- key: "url",
1046
- match: {
1047
- text: urlPrefix
1048
- }
1049
- }
1050
- ],
1051
- must_not: [
1052
- {
1053
- key: "is_metadata",
1054
- match: {
1055
- value: true
1056
- }
1057
- }
1058
- ]
1059
- }
1060
- });
1061
-
1062
- const obsoletePointIds = response.points
1063
- .filter((point: any) => {
1064
- const url = point.payload?.url;
1065
- // Double check it's not a metadata record
1066
- if (point.payload?.is_metadata === true) {
1067
- return false;
1068
- }
1069
-
1070
- if (!url || !url.startsWith(urlPrefix)) {
1071
- return false;
1072
- }
1073
-
1074
- let filePath: string;
1075
-
1076
- if (isRewriteMode) {
1077
- // URL rewrite mode: extract relative path and construct full file path
1078
- const config = pathConfig as { path: string; url_rewrite_prefix?: string };
1079
- const relativePath = url.substring(urlPrefix.length + 1); // +1 for the '/'
1080
- filePath = path.join(config.path, relativePath);
1081
- } else {
1082
- // Direct file path mode: remove file:// prefix to match with processedFiles
1083
- filePath = url.startsWith('file://') ? url.substring(7) : '';
1084
- }
1085
-
1086
- return filePath && !processedFiles.has(filePath);
1087
- })
1088
- .map((point: any) => point.id);
1089
-
1090
- if (obsoletePointIds.length > 0) {
1091
- await client.delete(collectionName, {
1092
- points: obsoletePointIds,
1093
- });
1094
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL prefix ${urlPrefix}`);
1095
- } else {
1096
- logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
1097
- }
1098
- } catch (error) {
1099
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1100
- }
1101
- }
1102
-
1103
- private async initDatabase(config: SourceConfig, parentLogger: Logger): Promise<DatabaseConnection> {
1104
- const logger = parentLogger.child('database');
1105
- const dbConfig = config.database_config;
1106
-
1107
- if (dbConfig.type === 'sqlite') {
1108
- const params = dbConfig.params as SqliteDatabaseParams;
1109
- const dbPath = params.db_path || path.join(process.cwd(), `${config.product_name.replace(/\s+/g, '_')}-${config.version}.db`);
1110
-
1111
- logger.info(`Opening SQLite database at ${dbPath}`);
1112
-
1113
- const db = new BetterSqlite3(dbPath, { allowExtension: true } as any);
1114
- sqliteVec.load(db);
1115
-
1116
- logger.debug(`Creating vec_items table if it doesn't exist`);
1117
- db.exec(`
1118
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
1119
- embedding FLOAT[3072],
1120
- product_name TEXT,
1121
- version TEXT,
1122
- heading_hierarchy TEXT,
1123
- section TEXT,
1124
- chunk_id TEXT UNIQUE,
1125
- content TEXT,
1126
- url TEXT,
1127
- hash TEXT
1128
- );
1129
- `);
1130
- logger.info(`SQLite database initialized successfully`);
1131
- return { db, type: 'sqlite' };
1132
- } else if (dbConfig.type === 'qdrant') {
1133
- const params = dbConfig.params as QdrantDatabaseParams;
1134
- const qdrantUrl = params.qdrant_url || 'http://localhost:6333';
1135
- const qdrantPort = params.qdrant_port || 443;
1136
- const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
1137
-
1138
- logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
1139
- const qdrantClient = new QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
1140
-
1141
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
1142
- logger.info(`Qdrant connection established successfully`);
1143
- return { client: qdrantClient, collectionName, type: 'qdrant' };
1144
- } else {
1145
- const errMsg = `Unsupported database type: ${dbConfig.type}`;
1146
- logger.error(errMsg);
1147
- throw new Error(errMsg);
1148
- }
1149
- }
1150
-
1151
- private async createCollectionQdrant(qdrantClient: QdrantClient, collectionName: string, logger: Logger) {
1152
- try {
1153
- logger.debug(`Checking if collection ${collectionName} exists`);
1154
- const collections = await qdrantClient.getCollections();
1155
- const collectionExists = collections.collections.some(
1156
- (collection: any) => collection.name === collectionName
1157
- );
1158
-
1159
- if (collectionExists) {
1160
- logger.info(`Collection ${collectionName} already exists`);
1161
- return;
1162
- }
1163
-
1164
- logger.info(`Creating new collection ${collectionName}`);
1165
- await qdrantClient.createCollection(collectionName, {
1166
- vectors: {
1167
- size: 3072,
1168
- distance: "Cosine",
1169
- },
1170
- });
1171
- logger.info(`Collection ${collectionName} created successfully`);
1172
- } catch (error) {
1173
- if (error instanceof Error) {
1174
- const errorMsg = error.message.toLowerCase();
1175
- const errorString = JSON.stringify(error).toLowerCase();
1176
-
1177
- if (
1178
- errorMsg.includes("already exists") ||
1179
- errorString.includes("already exists") ||
1180
- (error as any)?.status === 409 ||
1181
- errorString.includes("conflict")
1182
- ) {
1183
- logger.info(`Collection ${collectionName} already exists (from error response)`);
1184
- return;
1185
- }
1186
- }
1187
-
1188
- logger.error(`Error creating Qdrant collection:`, error);
1189
- logger.warn(`Continuing with existing collection...`);
1190
- }
1191
- }
1192
-
1193
- private prepareSQLiteStatements(db: Database) {
1194
- return {
1195
- insertStmt: db.prepare(`
1196
- INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
1197
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1198
- `),
1199
- checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
1200
- updateStmt: db.prepare(`
1201
- UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
1202
- WHERE chunk_id = ?
1203
- `),
1204
- getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
1205
- deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`)
1206
- };
1207
- }
1208
-
1209
- private insertVectorsSQLite(db: Database, chunk: DocumentChunk, embedding: number[], logger: Logger, chunkHash?: string) {
1210
- const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
1211
- const hash = chunkHash || this.generateHash(chunk.content);
1212
-
1213
- const transaction = db.transaction(() => {
1214
- const params = [
1215
- new Float32Array(embedding),
1216
- chunk.metadata.product_name,
1217
- chunk.metadata.version,
1218
- JSON.stringify(chunk.metadata.heading_hierarchy),
1219
- chunk.metadata.section,
1220
- chunk.metadata.chunk_id,
1221
- chunk.content,
1222
- chunk.metadata.url,
1223
- hash
1224
- ];
1225
-
1226
- try {
1227
- insertStmt.run(params);
1228
- } catch (error) {
1229
- updateStmt.run([...params.slice(0, 8), chunk.metadata.chunk_id]);
1230
- }
1231
- });
1232
-
1233
- transaction();
1234
- }
1235
-
1236
- private async storeChunkInQdrant(db: QdrantDB, chunk: DocumentChunk, embedding: number[], chunkHash?: string) {
1237
- const { client, collectionName } = db;
1238
- try {
1239
- let pointId: string;
1240
- try {
1241
- pointId = chunk.metadata.chunk_id;
1242
- if (!this.isValidUuid(pointId)) {
1243
- pointId = this.hashToUuid(chunk.metadata.chunk_id);
1244
- }
1245
- } catch (e) {
1246
- pointId = crypto.randomUUID();
1247
- }
1248
-
1249
- const hash = chunkHash || this.generateHash(chunk.content);
1250
-
1251
- const pointItem = {
1252
- id: pointId,
1253
- vector: embedding,
1254
- payload: {
1255
- content: chunk.content,
1256
- product_name: chunk.metadata.product_name,
1257
- version: chunk.metadata.version,
1258
- heading_hierarchy: chunk.metadata.heading_hierarchy,
1259
- section: chunk.metadata.section,
1260
- url: chunk.metadata.url,
1261
- hash: hash,
1262
- original_chunk_id: chunk.metadata.chunk_id,
1263
- },
1264
- };
1265
-
1266
- await client.upsert(collectionName, {
1267
- wait: true,
1268
- points: [pointItem],
1269
- });
1270
- } catch (error) {
1271
- this.logger.error("Error storing chunk in Qdrant:", error);
1272
- }
1273
- }
1274
-
1275
- private removeObsoleteChunksSQLite(db: Database, visitedUrls: Set<string>, urlPrefix: string, logger: Logger) {
1276
- const getChunksForUrlStmt = db.prepare(`
1277
- SELECT chunk_id, url FROM vec_items
1278
- WHERE url LIKE ? || '%'
1279
- `);
1280
- const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
1281
-
1282
- const existingChunks = getChunksForUrlStmt.all(urlPrefix) as { chunk_id: string; url: string }[];
1283
- let deletedCount = 0;
1284
-
1285
- const transaction = db.transaction(() => {
1286
- for (const { chunk_id, url } of existingChunks) {
1287
- if (!visitedUrls.has(url)) {
1288
- logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
1289
- deleteChunkStmt.run(chunk_id);
1290
- deletedCount++;
1291
- }
1292
- }
1293
- });
1294
- transaction();
1295
-
1296
- logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
1297
- }
1298
-
1299
- private isValidUuid(str: string): boolean {
1300
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1301
- return uuidRegex.test(str);
1302
- }
1303
-
1304
- private hashToUuid(hash: string): string {
1305
- const truncatedHash = hash.substring(0, 32);
1306
-
1307
- return [
1308
- truncatedHash.substring(0, 8),
1309
- truncatedHash.substring(8, 12),
1310
- '5' + truncatedHash.substring(13, 16),
1311
- '8' + truncatedHash.substring(17, 20),
1312
- truncatedHash.substring(20, 32)
1313
- ].join('-');
1314
- }
1315
-
1316
- private async removeObsoleteChunksQdrant(db: QdrantDB, visitedUrls: Set<string>, urlPrefix: string, logger: Logger) {
1317
- const { client, collectionName } = db;
1318
- try {
1319
- // Get all points that match the URL prefix but are not metadata points
1320
- const response = await client.scroll(collectionName, {
1321
- limit: 10000,
1322
- with_payload: true,
1323
- with_vector: false,
1324
- filter: {
1325
- must: [
1326
- {
1327
- key: "url",
1328
- match: {
1329
- text: urlPrefix
1330
- }
1331
- }
1332
- ],
1333
- must_not: [
1334
- {
1335
- key: "is_metadata",
1336
- match: {
1337
- value: true
1338
- }
1339
- }
1340
- ]
1341
- }
1342
- });
1343
-
1344
- const obsoletePointIds = response.points
1345
- .filter((point: any) => {
1346
- const url = point.payload?.url;
1347
- // Double check it's not a metadata record
1348
- if (point.payload?.is_metadata === true) {
1349
- return false;
1350
- }
1351
- return url && !visitedUrls.has(url);
1352
- })
1353
- .map((point: any) => point.id);
1354
-
1355
- if (obsoletePointIds.length > 0) {
1356
- await client.delete(collectionName, {
1357
- points: obsoletePointIds,
1358
- });
1359
- logger.info(`Deleted ${obsoletePointIds.length} obsolete chunks from Qdrant for URL ${urlPrefix}`);
1360
- } else {
1361
- logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
1362
- }
1363
- } catch (error) {
1364
- logger.error(`Error removing obsolete chunks from Qdrant:`, error);
1365
- }
1366
- }
1367
-
1368
- private async parseSitemap(sitemapUrl: string, logger: Logger): Promise<string[]> {
1369
- logger.info(`Parsing sitemap from ${sitemapUrl}`);
1370
- try {
1371
- const response = await axios.get(sitemapUrl);
1372
- const $ = load(response.data, { xmlMode: true });
1373
-
1374
- const urls: string[] = [];
1375
-
1376
- // Handle standard sitemaps
1377
- $('url > loc').each((_, element) => {
1378
- const url = $(element).text().trim();
1379
- if (url) {
1380
- urls.push(url);
1381
- }
1382
- });
1383
-
1384
- // Handle sitemap indexes (sitemaps that link to other sitemaps)
1385
- const sitemapLinks: string[] = [];
1386
- $('sitemap > loc').each((_, element) => {
1387
- const nestedSitemapUrl = $(element).text().trim();
1388
- if (nestedSitemapUrl) {
1389
- sitemapLinks.push(nestedSitemapUrl);
1390
- }
1391
- });
1392
-
1393
- // Recursively process nested sitemaps
1394
- for (const nestedSitemapUrl of sitemapLinks) {
1395
- logger.debug(`Found nested sitemap: ${nestedSitemapUrl}`);
1396
- const nestedUrls = await this.parseSitemap(nestedSitemapUrl, logger);
1397
- urls.push(...nestedUrls);
1398
- }
1399
-
1400
- logger.info(`Found ${urls.length} URLs in sitemap ${sitemapUrl}`);
1401
- return urls;
1402
- } catch (error) {
1403
- logger.error(`Error parsing sitemap at ${sitemapUrl}:`, error);
1404
- return [];
1405
- }
1406
- }
1407
-
1408
- private async crawlWebsite(
1409
- baseUrl: string,
1410
- sourceConfig: WebsiteSourceConfig,
1411
- processPageContent: (url: string, content: string) => Promise<void>,
1412
- parentLogger: Logger,
1413
- visitedUrls: Set<string>
1414
- ): Promise<void> {
1415
- const logger = parentLogger.child('crawler');
1416
- const queue: string[] = [baseUrl];
1417
-
1418
- // Process sitemap if provided
1419
- if (sourceConfig.sitemap_url) {
1420
- logger.section('SITEMAP PROCESSING');
1421
- const sitemapUrls = await this.parseSitemap(sourceConfig.sitemap_url, logger);
1422
-
1423
- // Add sitemap URLs to the queue if they're within the website scope
1424
- for (const url of sitemapUrls) {
1425
- if (url.startsWith(sourceConfig.url) && !queue.includes(url)) {
1426
- logger.debug(`Adding URL from sitemap to queue: ${url}`);
1427
- queue.push(url);
1428
- }
1429
- }
1430
-
1431
- logger.info(`Added ${queue.length - 1} URLs from sitemap to the crawl queue`);
1432
- }
1433
-
1434
- logger.info(`Starting crawl from ${baseUrl} with ${queue.length} URLs in initial queue`);
1435
- let processedCount = 0;
1436
- let skippedCount = 0;
1437
- let skippedSizeCount = 0;
1438
- let errorCount = 0;
1439
-
1440
- while (queue.length > 0) {
1441
- const url = queue.shift();
1442
- if (!url) continue;
1443
-
1444
- const normalizedUrl = this.normalizeUrl(url);
1445
- if (visitedUrls.has(normalizedUrl)) continue;
1446
- visitedUrls.add(normalizedUrl);
1447
-
1448
- if (!this.shouldProcessUrl(url)) {
1449
- logger.debug(`Skipping URL with unsupported extension: ${url}`);
1450
- skippedCount++;
1451
- continue;
1452
- }
1453
-
1454
- try {
1455
- logger.info(`Crawling: ${url}`);
1456
- const content = await this.processPage(url, sourceConfig);
1457
-
1458
- if (content !== null) {
1459
- await processPageContent(url, content);
1460
- processedCount++;
1461
- } else {
1462
- skippedSizeCount++;
1463
- }
1464
-
1465
- const response = await axios.get(url);
1466
- const $ = load(response.data);
1467
-
1468
- logger.debug(`Finding links on page ${url}`);
1469
- let newLinksFound = 0;
1470
-
1471
- $('a[href]').each((_, element) => {
1472
- const href = $(element).attr('href');
1473
- if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
1474
-
1475
- const fullUrl = this.buildUrl(href, url);
1476
- if (fullUrl.startsWith(sourceConfig.url) && !visitedUrls.has(this.normalizeUrl(fullUrl))) {
1477
- if (!queue.includes(fullUrl)) {
1478
- queue.push(fullUrl);
1479
- newLinksFound++;
1480
- }
1481
- }
1482
- });
1483
-
1484
- logger.debug(`Found ${newLinksFound} new links on ${url}`);
1485
- } catch (error) {
1486
- logger.error(`Failed during link discovery or initial fetch for ${url}:`, error);
1487
- errorCount++;
1488
- }
1489
- }
1490
-
1491
- logger.info(`Crawl completed. Processed: ${processedCount}, Skipped (Extension): ${skippedCount}, Skipped (Size): ${skippedSizeCount}, Errors: ${errorCount}`);
1492
- }
1493
-
1494
- private shouldProcessUrl(url: string): boolean {
1495
- const parsedUrl = new URL(url);
1496
- const pathname = parsedUrl.pathname;
1497
- const ext = path.extname(pathname);
1498
-
1499
- if (!ext) return true;
1500
- return ['.html', '.htm'].includes(ext.toLowerCase());
1501
- }
1502
-
1503
- private normalizeUrl(url: string): string {
1504
- try {
1505
- const urlObj = new URL(url);
1506
- urlObj.hash = '';
1507
- urlObj.search = '';
1508
- return urlObj.toString();
1509
- } catch (error) {
1510
- return url;
1511
- }
1512
- }
1513
-
1514
- private buildUrl(href: string, currentUrl: string): string {
1515
- try {
1516
- return new URL(href, currentUrl).toString();
1517
- } catch (error) {
1518
- this.logger.warn(`Invalid URL found: ${href}`);
1519
- return '';
1520
- }
1521
- }
1522
-
1523
- private async processPage(url: string, sourceConfig: SourceConfig): Promise<string | null> {
1524
- const logger = this.logger.child('page-processor');
1525
- logger.debug(`Processing page content from ${url}`);
1526
-
1527
- let browser: Browser | null = null;
1528
- try {
1529
- browser = await puppeteer.launch({
1530
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
1531
- });
1532
- const page: Page = await browser.newPage();
1533
- logger.debug(`Navigating to ${url}`);
1534
- await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
1535
-
1536
- const htmlContent: string = await page.evaluate(() => {
1537
- const mainContentElement = document.querySelector('div[role="main"].document') || document.querySelector('main') || document.body;
1538
- return mainContentElement.innerHTML;
1539
- });
1540
-
1541
- if (htmlContent.length > sourceConfig.max_size) {
1542
- logger.warn(`Raw HTML content (${htmlContent.length} chars) exceeds max size (${sourceConfig.max_size}). Skipping detailed processing for ${url}.`);
1543
- await browser.close();
1544
- return null;
1545
- }
1546
-
1547
- logger.debug(`Got HTML content (${htmlContent.length} chars), creating DOM`);
1548
- const dom = new JSDOM(htmlContent);
1549
- const document = dom.window.document;
1550
-
1551
- document.querySelectorAll('pre').forEach((pre: HTMLElement) => {
1552
- pre.classList.add('article-content');
1553
- pre.setAttribute('data-readable-content-score', '100');
1554
- this.markCodeParents(pre.parentElement);
1555
- });
1556
-
1557
- logger.debug(`Applying Readability to extract main content`);
1558
- const reader = new Readability(document, {
1559
- charThreshold: 20,
1560
- classesToPreserve: ['article-content'],
1561
- });
1562
- const article = reader.parse();
1563
-
1564
- if (!article) {
1565
- logger.warn(`Failed to parse article content with Readability for ${url}`);
1566
- await browser.close();
1567
- return null;
1568
- }
1569
-
1570
- logger.debug(`Sanitizing HTML (${article.content.length} chars)`);
1571
- const cleanHtml = sanitizeHtml(article.content, {
1572
- allowedTags: [
1573
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
1574
- 'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
1575
- 'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
1576
- ],
1577
- allowedAttributes: {
1578
- 'a': ['href'],
1579
- 'pre': ['class', 'data-language'],
1580
- 'code': ['class', 'data-language'],
1581
- 'div': ['class'],
1582
- 'span': ['class']
1583
- }
1584
- });
1585
-
1586
- logger.debug(`Converting HTML to Markdown`);
1587
- const markdown = this.turndownService.turndown(cleanHtml);
1588
- logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
1589
- return markdown;
1590
- } catch (error) {
1591
- logger.error(`Error processing page ${url}:`, error);
1592
- return null;
1593
- } finally {
1594
- if (browser && browser.isConnected()) {
1595
- await browser.close();
1596
- logger.debug(`Browser closed for ${url}`);
1597
- }
1598
- }
1599
- }
1600
-
1601
- private markCodeParents(node: Element | null) {
1602
- if (!node) return;
1603
-
1604
- if (node.querySelector('pre, code')) {
1605
- node.classList.add('article-content');
1606
- node.setAttribute('data-readable-content-score', '100');
1607
- }
1608
- this.markCodeParents(node.parentElement);
1609
- }
1610
-
1611
- private async chunkMarkdown(markdown: string, sourceConfig: SourceConfig, url: string): Promise<DocumentChunk[]> {
1612
- const logger = this.logger.child('chunker');
1613
- logger.debug(`Chunking markdown from ${url} (${markdown.length} chars)`);
1614
-
1615
- const MAX_TOKENS = 1000;
1616
- const chunks: DocumentChunk[] = [];
1617
- const lines = markdown.split("\n");
1618
- let currentChunk = "";
1619
- let headingHierarchy: string[] = [];
1620
-
1621
- const processChunk = () => {
1622
- if (currentChunk.trim()) {
1623
- const tokens = this.tokenize(currentChunk);
1624
- if (tokens.length > MAX_TOKENS) {
1625
- logger.debug(`Chunk exceeds max token count (${tokens.length}), splitting into smaller chunks`);
1626
- let subChunk = "";
1627
- let tokenCount = 0;
1628
- const overlapSize = Math.floor(MAX_TOKENS * 0.05);
1629
- let lastTokens: string[] = [];
1630
-
1631
- for (const token of tokens) {
1632
- if (tokenCount + 1 > MAX_TOKENS) {
1633
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1634
- subChunk = lastTokens.join("") + token;
1635
- tokenCount = lastTokens.length + 1;
1636
- lastTokens = [];
1637
- } else {
1638
- subChunk += token;
1639
- tokenCount++;
1640
- lastTokens.push(token);
1641
- if (lastTokens.length > overlapSize) {
1642
- lastTokens.shift();
1643
- }
1644
- }
1645
- }
1646
- if (subChunk) {
1647
- chunks.push(createDocumentChunk(subChunk, headingHierarchy));
1648
- }
1649
- } else {
1650
- chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
1651
- }
1652
- }
1653
- currentChunk = "";
1654
- };
1655
-
1656
- const createDocumentChunk = (content: string, hierarchy: string[]): DocumentChunk => {
1657
- const chunkId = this.generateHash(content);
1658
- logger.debug(`Created chunk ${chunkId.substring(0, 8)}... with ${content.length} chars`);
1659
-
1660
- return {
1661
- content,
1662
- metadata: {
1663
- product_name: sourceConfig.product_name,
1664
- version: sourceConfig.version,
1665
- heading_hierarchy: [...hierarchy],
1666
- section: hierarchy[hierarchy.length - 1] || "Introduction",
1667
- chunk_id: chunkId,
1668
- url: url,
1669
- hash: this.generateHash(content)
1670
- }
1671
- };
1672
- };
1673
-
1674
- for (const line of lines) {
1675
- if (line.startsWith("#")) {
1676
- processChunk();
1677
- const levelMatch = line.match(/^(#+)/);
1678
- let level = levelMatch ? levelMatch[1].length : 1;
1679
- const heading = line.replace(/^#+\s*/, "").trim();
1680
-
1681
- logger.debug(`Found heading (level ${level}): ${heading}`);
1682
-
1683
- while (headingHierarchy.length < level - 1) {
1684
- headingHierarchy.push("");
1685
- }
1686
-
1687
- if (level <= headingHierarchy.length) {
1688
- headingHierarchy = headingHierarchy.slice(0, level - 1);
1689
- }
1690
- headingHierarchy[level - 1] = heading;
1691
- } else {
1692
- currentChunk += `${line}\n`;
1693
- }
1694
- }
1695
- processChunk();
1696
-
1697
- logger.debug(`Chunking complete, created ${chunks.length} chunks`);
1698
- return chunks;
1699
- }
1700
-
1701
- private tokenize(text: string): string[] {
1702
- return text.split(/(\s+)/).filter(token => token.length > 0);
1703
- }
1704
-
1705
- private async createEmbeddings(texts: string[]): Promise<number[][]> {
1706
- const logger = this.logger.child('embeddings');
1707
- try {
1708
- logger.debug(`Creating embeddings for ${texts.length} texts`);
1709
- const response = await this.openai.embeddings.create({
1710
- model: "text-embedding-3-large",
1711
- input: texts,
1712
- });
1713
- logger.debug(`Successfully created ${response.data.length} embeddings`);
1714
- return response.data.map(d => d.embedding);
1715
- } catch (error) {
1716
- logger.error('Failed to create embeddings:', error);
1717
- return [];
1718
- }
1719
- }
1720
-
1721
- private generateHash(content: string): string {
1722
- return crypto.createHash("sha256").update(content).digest("hex");
1723
- }
1724
- }
1725
-
1726
- if (require.main === module) {
1727
- const configPath = process.argv[2] || 'config.yaml';
1728
- if (!fs.existsSync(configPath)) {
1729
- console.error('Please provide a valid path to a YAML config file.');
1730
- process.exit(1);
1731
- }
1732
- const doc2Vec = new Doc2Vec(configPath);
1733
- doc2Vec.run().catch(console.error);
1734
- }