doc2vec 2.4.0 → 2.6.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 CHANGED
@@ -36,6 +36,10 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
36
36
  * **Vector Storage:** Supports storing chunks, metadata, and embeddings in:
37
37
  * **SQLite:** Using `better-sqlite3` and the `sqlite-vec` extension for efficient vector search.
38
38
  * **Qdrant:** A dedicated vector database, using the `@qdrant/js-client-rest`.
39
+ * **[Postgres Markdown Store](MARKDOWN_STORE.md):** Optionally stores the generated markdown for each crawled URL in a Postgres table. Useful for maintaining a searchable, raw-text copy of all documentation pages alongside the vector embeddings.
40
+ * **Automatic population:** On the first sync, all pages are force-processed (bypassing lastmod/ETag caching) to fully populate the store. Subsequent syncs only update rows when a change is detected.
41
+ * **404 cleanup:** Pages that return 404 are automatically removed from the store.
42
+ * **Shared table:** A single table (configurable name, default `markdown_pages`) is shared across all sources, with a `product_name` column to distinguish them.
39
43
  * **Multi-Layer Change Detection:** Four layers of change detection minimize unnecessary re-processing:
40
44
  1. **Sitemap `lastmod`:** When available, compares the sitemap's `<lastmod>` date against the stored value — skips without any HTTP request. Child URLs inherit `lastmod` from their most specific parent directory.
41
45
  2. **ETag via HEAD request:** For URLs without `lastmod`, sends a lightweight HEAD request and compares the ETag header against the stored value. Adaptive backoff prevents rate limiting (starts at 0ms delay, increases on 429 responses, decays on success).
@@ -43,6 +47,8 @@ The primary goal is to prepare documentation content for Retrieval-Augmented Gen
43
47
  4. **Embedding:** Only re-embeds chunks when content has actually changed.
44
48
 
45
49
  ETag and lastmod values are only stored when chunking and embedding succeed, ensuring failed pages are retried on the next run.
50
+
51
+ A `sync_complete` metadata flag tracks whether a full sync has ever completed successfully. If a sync is interrupted (process killed), the next run force-processes all pages regardless of lastmod/ETag values, ensuring no pages are permanently skipped.
46
52
  * **Incremental Updates:** For GitHub and Zendesk sources, tracks the last run date to only fetch new or updated issues/tickets.
47
53
  * **Cleanup:** Removes obsolete chunks from the database corresponding to pages or files that are no longer found during processing.
48
54
  * **Configuration:** Driven by a YAML configuration file (`config.yaml`) specifying sites, repositories, local directories, Zendesk instances, database types, metadata, and other parameters.
@@ -145,6 +151,9 @@ Configuration is managed through two files:
145
151
  OPENAI_API_KEY="sk-..."
146
152
  OPENAI_MODEL="text-embedding-3-large" # Optional, defaults to text-embedding-3-large
147
153
 
154
+ # Optional: Embedding dimension size (defaults to 3072)
155
+ EMBEDDING_DIMENSION="3072"
156
+
148
157
  # Required: Your Azure OpenAI credentials (if using Azure provider)
149
158
  AZURE_OPENAI_KEY="your-azure-key"
150
159
  AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com"
@@ -172,6 +181,7 @@ Configuration is managed through two files:
172
181
  For websites (`type: 'website'`):
173
182
  * `url`: The starting URL for crawling the documentation site.
174
183
  * `sitemap_url`: (Optional) URL to the site's XML sitemap for discovering additional pages not linked in navigation.
184
+ * `markdown_store`: (Optional) Set to `true` to store generated markdown in the Postgres markdown store (requires top-level `markdown_store` config). Defaults to `false`.
175
185
 
176
186
  For GitHub repositories (`type: 'github'`):
177
187
  * `repo`: Repository name in the format `'owner/repo'` (e.g., `'istio/istio'`).
@@ -223,6 +233,21 @@ Configuration is managed through two files:
223
233
  * `qdrant_port`: (Optional) Port for the Qdrant REST API. Defaults to `443` if `qdrant_url` starts with `https`, otherwise `6333`.
224
234
  * `collection_name`: (Optional) Name of the Qdrant collection to use. Defaults to `<product_name>_<version>` (lowercased, spaces replaced with underscores).
225
235
 
236
+ Optional embedding configuration:
237
+ * `embedding.provider`: Provider for embeddings (`openai` or `azure`).
238
+ * `embedding.dimension`: Embedding vector size. Defaults to `3072` when not set.
239
+
240
+ Optional Postgres markdown store (top-level):
241
+ * `markdown_store.connection_string`: (Optional) Full Postgres connection string (e.g., `'postgres://user:pass@host:5432/db'`). Takes priority over individual fields.
242
+ * `markdown_store.host`: (Optional) Postgres host.
243
+ * `markdown_store.port`: (Optional) Postgres port.
244
+ * `markdown_store.database`: (Optional) Postgres database name.
245
+ * `markdown_store.user`: (Optional) Postgres user.
246
+ * `markdown_store.password`: (Optional) Postgres password. Supports `${PG_PASSWORD}` env var substitution.
247
+ * `markdown_store.table_name`: (Optional) Table name. Defaults to `'markdown_pages'`.
248
+
249
+ When configured, website sources with `markdown_store: true` will store the generated markdown for each URL in this Postgres table. On the first sync, all pages are force-processed (bypassing lastmod/ETag skip logic) to populate the table. On subsequent syncs, only pages with detected changes get their rows updated.
250
+
226
251
  **Example (`config.yaml`):**
227
252
  ```yaml
228
253
  # Optional: Configure embedding provider
@@ -230,6 +255,7 @@ Configuration is managed through two files:
230
255
  # Defaults to OpenAI if not specified
231
256
  embedding:
232
257
  provider: 'openai' # or 'azure'
258
+ dimension: 3072 # Optional, defaults to 3072
233
259
  openai:
234
260
  api_key: '${OPENAI_API_KEY}' # Optional, uses env var by default
235
261
  model: 'text-embedding-3-large' # Optional, defaults to text-embedding-3-large
@@ -240,13 +266,25 @@ Configuration is managed through two files:
240
266
  # deployment_name: 'text-embedding-3-large'
241
267
  # api_version: '2024-10-21' # Optional
242
268
 
269
+ # Optional: Store generated markdown in Postgres
270
+ # markdown_store:
271
+ # connection_string: 'postgres://user:pass@host:5432/db'
272
+ # # OR use individual fields:
273
+ # # host: 'localhost'
274
+ # # port: 5432
275
+ # # database: 'doc2vec'
276
+ # # user: 'myuser'
277
+ # # password: '${PG_PASSWORD}'
278
+ # # table_name: 'markdown_pages' # Optional, defaults to 'markdown_pages'
279
+
243
280
  sources:
244
- # Website source example
281
+ # Website source example (with markdown store enabled)
245
282
  - type: 'website'
246
283
  product_name: 'argo'
247
284
  version: 'stable'
248
285
  url: 'https://argo-cd.readthedocs.io/en/stable/'
249
286
  sitemap_url: 'https://argo-cd.readthedocs.io/en/stable/sitemap.xml'
287
+ markdown_store: true # Store generated markdown in Postgres
250
288
  max_size: 1048576
251
289
  database_config:
252
290
  type: 'sqlite'
@@ -540,6 +578,20 @@ If you don't specify a config path, it will look for config.yaml in the current
540
578
 
541
579
  ## Recent Changes
542
580
 
581
+ ### Postgres Markdown Store
582
+ - **New feature:** Optionally store the generated markdown for each crawled website URL in a Postgres table (`url`, `product_name`, `markdown`, `updated_at`)
583
+ - **Top-level configuration:** Configure Postgres connection once at the top level via `connection_string` or individual `host`/`port`/`database`/`user`/`password` fields, with environment variable substitution support
584
+ - **Per-source opt-in:** Enable per website source with `markdown_store: true` (disabled by default)
585
+ - **First-sync force-processing:** When the markdown store is enabled, pages that aren't yet in the Postgres table bypass lastmod and ETag skip logic, ensuring all pages are stored on the first sync
586
+ - **Change-only updates:** On subsequent syncs, only pages with detected content changes (via lastmod/ETag) have their Postgres rows updated
587
+ - **404 cleanup:** Pages that return 404 during HEAD checks are automatically removed from the Postgres store
588
+
589
+ ### Incomplete Sync Recovery
590
+ - **New feature:** Tracks whether a full sync has ever completed successfully for each website source via a `sync_complete:<url_prefix>` metadata key
591
+ - **Interrupted sync handling:** If a sync is killed mid-crawl (process terminated, crash, etc.), the stored ETags/lastmods from the partial run would otherwise cause remaining pages to be skipped permanently. The `sync_complete` flag prevents this — when absent, all pages are force-processed regardless of caching signals
592
+ - **Gated on clean completion:** The flag is only set when the crawl completes without network errors (DNS failures, connection refused, timeouts). If the site is unreachable, the next run will force a full sync again
593
+ - **Scoped per source:** Each website source has its own `sync_complete` key based on its URL prefix. Changing the source URL naturally triggers a new full sync
594
+
543
595
  ### Multi-Layer Change Detection for Websites
544
596
  - **Sitemap `lastmod` support:** When a sitemap includes `<lastmod>` dates, pages are skipped entirely if the date hasn't changed — no HEAD request, no Puppeteer load, no chunking. One sitemap fetch replaces hundreds of individual HEAD requests.
545
597
  - **`lastmod` inheritance:** Child URLs without their own `<lastmod>` inherit from the most specific parent directory URL in the sitemap (e.g., `/docs/2.10.x/reference/cli/` inherits from `/docs/2.10.x/`).
@@ -218,6 +218,7 @@ class ContentProcessor {
218
218
  const queue = [baseUrl];
219
219
  const brokenLinks = [];
220
220
  const brokenLinkKeys = new Set();
221
+ const notFoundUrls = new Set();
221
222
  const referrers = new Map();
222
223
  const addReferrer = (targetUrl, sourceUrl) => {
223
224
  const existing = referrers.get(targetUrl);
@@ -236,7 +237,7 @@ class ContentProcessor {
236
237
  brokenLinks.push({ source: sourceUrl, target: targetUrl });
237
238
  };
238
239
  addReferrer(baseUrl, baseUrl);
239
- const { knownUrls, etagStore, lastmodStore } = options ?? {};
240
+ const { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync } = options ?? {};
240
241
  // Pre-seed queue with previously-known URLs so link discovery isn't lost
241
242
  // when pages are skipped via ETag/lastmod matching on re-runs
242
243
  if (knownUrls && knownUrls.size > 0) {
@@ -422,14 +423,26 @@ class ContentProcessor {
422
423
  // this URL and it matches the stored value, skip entirely. This avoids
423
424
  // both HEAD requests and full page loads — the sitemap is fetched once
424
425
  // for the entire crawl.
426
+ // Bypass this skip when:
427
+ // - forceFullSync is true (previous sync never completed)
428
+ // - markdownStoreUrls is set and the URL is not in it (markdown store gap)
425
429
  const urlLastmod = sitemapLastmod.get(url);
430
+ const urlInMarkdownStore = !markdownStoreUrls || markdownStoreUrls.has(url);
426
431
  if (urlLastmod && lastmodStore) {
427
432
  const storedLastmod = await lastmodStore.get(url);
428
433
  if (storedLastmod && storedLastmod === urlLastmod) {
429
- logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
430
- visitedUrls.add(url);
431
- lastmodSkippedCount++;
432
- continue;
434
+ if (forceFullSync) {
435
+ logger.info(`Lastmod unchanged but full sync not yet completed, forcing processing: ${url}`);
436
+ }
437
+ else if (!urlInMarkdownStore) {
438
+ logger.info(`Lastmod unchanged but URL not in markdown store, forcing processing: ${url}`);
439
+ }
440
+ else {
441
+ logger.info(`Lastmod unchanged (${urlLastmod}), skipping: ${url}`);
442
+ visitedUrls.add(url);
443
+ lastmodSkippedCount++;
444
+ continue;
445
+ }
433
446
  }
434
447
  else if (storedLastmod) {
435
448
  logger.debug(`Lastmod changed (stored: ${storedLastmod}, sitemap: ${urlLastmod}), processing: ${url}`);
@@ -494,10 +507,21 @@ class ContentProcessor {
494
507
  decayBackoff();
495
508
  const result = await checkEtag(headResponse.headers['etag']);
496
509
  if (result === 'skip') {
497
- logger.info(`ETag unchanged, skipping: ${url}`);
498
- visitedUrls.add(url);
499
- etagSkippedCount++;
500
- continue;
510
+ // Bypass ETag skip when:
511
+ // - forceFullSync is true (previous sync never completed)
512
+ // - markdownStoreUrls is set and URL is not in it
513
+ if (forceFullSync) {
514
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
515
+ }
516
+ else if (!urlInMarkdownStore) {
517
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
518
+ }
519
+ else {
520
+ logger.info(`ETag unchanged, skipping: ${url}`);
521
+ visitedUrls.add(url);
522
+ etagSkippedCount++;
523
+ continue;
524
+ }
501
525
  }
502
526
  else if (result === 'no-etag') {
503
527
  consecutiveMissingEtag++;
@@ -524,10 +548,18 @@ class ContentProcessor {
524
548
  decayBackoff();
525
549
  const result = await checkEtag(retryResponse.headers['etag']);
526
550
  if (result === 'skip') {
527
- logger.info(`ETag unchanged, skipping: ${url}`);
528
- visitedUrls.add(url);
529
- etagSkippedCount++;
530
- continue;
551
+ if (forceFullSync) {
552
+ logger.info(`ETag unchanged but full sync not yet completed, forcing processing: ${url}`);
553
+ }
554
+ else if (!urlInMarkdownStore) {
555
+ logger.info(`ETag unchanged but URL not in markdown store, forcing processing: ${url}`);
556
+ }
557
+ else {
558
+ logger.info(`ETag unchanged, skipping: ${url}`);
559
+ visitedUrls.add(url);
560
+ etagSkippedCount++;
561
+ continue;
562
+ }
531
563
  }
532
564
  // 'process' or 'no-etag' — fall through to full processing
533
565
  }
@@ -542,6 +574,9 @@ class ContentProcessor {
542
574
  // 405 (Method Not Allowed) is excluded because the server
543
575
  // may not support HEAD but the page may still exist via GET.
544
576
  logger.debug(`HEAD returned ${headStatus} for ${url}, skipping`);
577
+ if (headStatus === 404) {
578
+ notFoundUrls.add(url);
579
+ }
545
580
  skippedCount++;
546
581
  continue;
547
582
  }
@@ -586,7 +621,7 @@ class ContentProcessor {
586
621
  logger.debug(`Finding links on page ${url}`);
587
622
  let newLinksFound = 0;
588
623
  for (const href of result.links) {
589
- const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks);
624
+ const fullUrl = utils_1.Utils.buildUrl(href, pageUrlForLinks, logger);
590
625
  if (fullUrl.startsWith(sourceConfig.url)) {
591
626
  addReferrer(fullUrl, pageUrlForLinks);
592
627
  if (!visitedUrls.has(utils_1.Utils.normalizeUrl(fullUrl))) {
@@ -707,7 +742,7 @@ class ContentProcessor {
707
742
  if (hasNetworkErrors) {
708
743
  logger.warn('Network errors were encountered during crawling. Cleanup may be skipped to avoid removing valid chunks.');
709
744
  }
710
- return { hasNetworkErrors, brokenLinks };
745
+ return { hasNetworkErrors, brokenLinks, notFoundUrls };
711
746
  }
712
747
  /**
713
748
  * Detects Chrome DevTools Protocol-level errors that indicate the browser
package/dist/database.js CHANGED
@@ -44,7 +44,7 @@ const sqliteVec = __importStar(require("sqlite-vec"));
44
44
  const js_client_rest_1 = require("@qdrant/js-client-rest");
45
45
  const utils_1 = require("./utils");
46
46
  class DatabaseManager {
47
- static async initDatabase(config, parentLogger) {
47
+ static async initDatabase(config, parentLogger, embeddingDimension) {
48
48
  const logger = parentLogger.child('database');
49
49
  const dbConfig = config.database_config;
50
50
  if (dbConfig.type === 'sqlite') {
@@ -53,10 +53,10 @@ class DatabaseManager {
53
53
  logger.info(`Opening SQLite database at ${dbPath}`);
54
54
  const db = new better_sqlite3_1.default(dbPath, { allowExtension: true });
55
55
  sqliteVec.load(db);
56
- logger.debug(`Creating vec_items table if it doesn't exist`);
56
+ logger.debug(`Creating vec_items table if it doesn't exist (dimension: ${embeddingDimension})`);
57
57
  db.exec(`
58
58
  CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING vec0(
59
- embedding FLOAT[3072],
59
+ embedding FLOAT[${embeddingDimension}],
60
60
  product_name TEXT,
61
61
  version TEXT,
62
62
  branch TEXT,
@@ -81,7 +81,7 @@ class DatabaseManager {
81
81
  const collectionName = params.collection_name || `${config.product_name.toLowerCase().replace(/\s+/g, '_')}_${config.version}`;
82
82
  logger.info(`Connecting to Qdrant at ${qdrantUrl}:${qdrantPort}, collection: ${collectionName}`);
83
83
  const qdrantClient = new js_client_rest_1.QdrantClient({ url: qdrantUrl, apiKey: process.env.QDRANT_API_KEY, port: qdrantPort });
84
- await this.createCollectionQdrant(qdrantClient, collectionName, logger);
84
+ await this.createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension);
85
85
  logger.info(`Qdrant connection established successfully`);
86
86
  return { client: qdrantClient, collectionName, type: 'qdrant' };
87
87
  }
@@ -91,7 +91,7 @@ class DatabaseManager {
91
91
  throw new Error(errMsg);
92
92
  }
93
93
  }
94
- static async createCollectionQdrant(qdrantClient, collectionName, logger) {
94
+ static async createCollectionQdrant(qdrantClient, collectionName, logger, embeddingDimension) {
95
95
  try {
96
96
  logger.debug(`Checking if collection ${collectionName} exists`);
97
97
  const collections = await qdrantClient.getCollections();
@@ -100,10 +100,10 @@ class DatabaseManager {
100
100
  logger.info(`Collection ${collectionName} already exists`);
101
101
  return;
102
102
  }
103
- logger.info(`Creating new collection ${collectionName}`);
103
+ logger.info(`Creating new collection ${collectionName} with dimension ${embeddingDimension}`);
104
104
  await qdrantClient.createCollection(collectionName, {
105
105
  vectors: {
106
- size: 3072,
106
+ size: embeddingDimension,
107
107
  distance: "Cosine",
108
108
  },
109
109
  });
@@ -181,7 +181,7 @@ class DatabaseManager {
181
181
  }
182
182
  return defaultValue;
183
183
  }
184
- static async setMetadataValue(dbConnection, key, value, logger) {
184
+ static async setMetadataValue(dbConnection, key, value, logger, embeddingDimension) {
185
185
  try {
186
186
  if (dbConnection.type === 'sqlite') {
187
187
  const stmt = dbConnection.db.prepare(`
@@ -193,8 +193,7 @@ class DatabaseManager {
193
193
  }
194
194
  else if (dbConnection.type === 'qdrant') {
195
195
  const metadataUUID = utils_1.Utils.generateMetadataUUID(key);
196
- const dummyEmbeddingSize = 3072;
197
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
196
+ const dummyEmbedding = new Array(embeddingDimension).fill(0);
198
197
  const metadataPoint = {
199
198
  id: metadataUUID,
200
199
  vector: dummyEmbedding,
@@ -258,7 +257,7 @@ class DatabaseManager {
258
257
  logger.info(`No saved run date found for ${repo}, using default: ${defaultDate}`);
259
258
  return defaultDate;
260
259
  }
261
- static async updateLastRunDate(dbConnection, repo, logger) {
260
+ static async updateLastRunDate(dbConnection, repo, logger, embeddingDimension) {
262
261
  const now = new Date().toISOString();
263
262
  try {
264
263
  if (dbConnection.type === 'sqlite') {
@@ -276,8 +275,7 @@ class DatabaseManager {
276
275
  const metadataKey = `last_run_${repo.replace('/', '_')}`;
277
276
  logger.debug(`Using UUID: ${metadataUUID} for metadata`);
278
277
  // Generate a dummy embedding (all zeros)
279
- const dummyEmbeddingSize = 3072; // Same size as your content embeddings
280
- const dummyEmbedding = new Array(dummyEmbeddingSize).fill(0);
278
+ const dummyEmbedding = new Array(embeddingDimension).fill(0);
281
279
  // Create a point with special metadata payload
282
280
  const metadataPoint = {
283
281
  id: metadataUUID,
package/dist/doc2vec.js CHANGED
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
53
53
  const utils_1 = require("./utils");
54
54
  const database_1 = require("./database");
55
55
  const content_processor_1 = require("./content-processor");
56
+ const markdown_store_1 = require("./markdown-store");
56
57
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
57
58
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
58
59
  dotenv.config();
@@ -72,6 +73,7 @@ class Doc2Vec {
72
73
  // Check environment variable if not specified in config
73
74
  const embeddingProvider = this.config.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'openai';
74
75
  const embeddingConfig = this.config.embedding || { provider: embeddingProvider };
76
+ this.embeddingDimension = this.resolveEmbeddingDimension(embeddingConfig);
75
77
  if (embeddingProvider === 'azure') {
76
78
  const azureApiKey = embeddingConfig.azure?.api_key || process.env.AZURE_OPENAI_KEY;
77
79
  const azureEndpoint = embeddingConfig.azure?.endpoint || process.env.AZURE_OPENAI_ENDPOINT;
@@ -88,7 +90,7 @@ class Doc2Vec {
88
90
  apiVersion: azureApiVersion,
89
91
  });
90
92
  this.embeddingModel = azureDeploymentName;
91
- this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName}`);
93
+ this.logger.info(`Using Azure OpenAI with deployment: ${azureDeploymentName} (${this.embeddingDimension} dimensions)`);
92
94
  }
93
95
  else {
94
96
  const openaiApiKey = embeddingConfig.openai?.api_key || process.env.OPENAI_API_KEY;
@@ -99,9 +101,13 @@ class Doc2Vec {
99
101
  }
100
102
  this.openai = new openai_1.OpenAI({ apiKey: openaiApiKey });
101
103
  this.embeddingModel = openaiModel;
102
- this.logger.info(`Using OpenAI with model: ${openaiModel}`);
104
+ this.logger.info(`Using OpenAI with model: ${openaiModel} (${this.embeddingDimension} dimensions)`);
103
105
  }
104
106
  this.contentProcessor = new content_processor_1.ContentProcessor(this.logger);
107
+ // Initialize Postgres markdown store if configured
108
+ if (this.config.markdown_store) {
109
+ this.markdownStore = new markdown_store_1.MarkdownStore(this.config.markdown_store, this.logger);
110
+ }
105
111
  }
106
112
  loadConfig(configPath) {
107
113
  try {
@@ -144,7 +150,26 @@ class Doc2Vec {
144
150
  process.exit(1);
145
151
  }
146
152
  }
153
+ resolveEmbeddingDimension(embeddingConfig) {
154
+ const defaultDimension = 3072;
155
+ const rawConfigValue = embeddingConfig?.dimension;
156
+ const rawEnvValue = process.env.EMBEDDING_DIMENSION;
157
+ const candidate = rawConfigValue ?? (rawEnvValue ? Number(rawEnvValue) : undefined);
158
+ if (candidate === undefined) {
159
+ return defaultDimension;
160
+ }
161
+ const parsedValue = typeof candidate === 'string' ? Number(candidate) : candidate;
162
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0 || !Number.isInteger(parsedValue)) {
163
+ this.logger.warn(`Invalid embedding dimension provided (${candidate}), falling back to ${defaultDimension}`);
164
+ return defaultDimension;
165
+ }
166
+ return parsedValue;
167
+ }
147
168
  async run() {
169
+ // Initialize Postgres markdown store table if configured
170
+ if (this.markdownStore) {
171
+ await this.markdownStore.init();
172
+ }
148
173
  this.logger.section('PROCESSING SOURCES');
149
174
  for (const sourceConfig of this.config.sources) {
150
175
  const sourceLogger = this.logger.child(`source:${sourceConfig.product_name}`);
@@ -168,6 +193,10 @@ class Doc2Vec {
168
193
  sourceLogger.error(`Unknown source type: ${sourceConfig.type}`);
169
194
  }
170
195
  }
196
+ // Close the Postgres markdown store connection pool
197
+ if (this.markdownStore) {
198
+ await this.markdownStore.close();
199
+ }
171
200
  this.logger.section('PROCESSING COMPLETE');
172
201
  }
173
202
  async fetchAndProcessGitHubIssues(repo, sourceConfig, dbConnection, logger) {
@@ -373,13 +402,13 @@ class Doc2Vec {
373
402
  await processIssue(issues[i]);
374
403
  }
375
404
  // Update the last run date in the database after processing all issues
376
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger);
405
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension);
377
406
  logger.info(`Successfully processed ${issues.length} issues`);
378
407
  }
379
408
  async processGithubRepo(config, parentLogger) {
380
409
  const logger = parentLogger.child('process');
381
410
  logger.info(`Starting processing for GitHub repo: ${config.repo}`);
382
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
411
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
383
412
  // Initialize metadata storage
384
413
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
385
414
  logger.section('GITHUB ISSUES');
@@ -390,7 +419,7 @@ class Doc2Vec {
390
419
  async processWebsite(config, parentLogger) {
391
420
  const logger = parentLogger.child('process');
392
421
  logger.info(`Starting processing for website: ${config.url}`);
393
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
422
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
394
423
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
395
424
  const validChunkIds = new Set();
396
425
  const visitedUrls = new Set();
@@ -420,7 +449,7 @@ class Doc2Vec {
420
449
  return database_1.DatabaseManager.getMetadataValue(dbConnection, `etag:${url}`, undefined, logger);
421
450
  },
422
451
  set: async (url, etag) => {
423
- await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger);
452
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `etag:${url}`, etag, logger, this.embeddingDimension);
424
453
  },
425
454
  };
426
455
  const lastmodStore = {
@@ -428,9 +457,31 @@ class Doc2Vec {
428
457
  return database_1.DatabaseManager.getMetadataValue(dbConnection, `lastmod:${url}`, undefined, logger);
429
458
  },
430
459
  set: async (url, lastmod) => {
431
- await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger);
460
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, `lastmod:${url}`, lastmod, logger, this.embeddingDimension);
432
461
  },
433
462
  };
463
+ // If Postgres markdown store is enabled for this source, load URLs that
464
+ // already have markdown stored. URLs NOT in this set will bypass
465
+ // lastmod/ETag skip logic so that the store is fully populated on the
466
+ // first sync.
467
+ const useMarkdownStore = config.markdown_store === true && this.markdownStore != null;
468
+ let markdownStoreUrls;
469
+ if (useMarkdownStore) {
470
+ markdownStoreUrls = await this.markdownStore.getUrlsWithMarkdown(urlPrefix);
471
+ logger.info(`Markdown store: ${markdownStoreUrls.size} URLs already stored for prefix ${urlPrefix}`);
472
+ }
473
+ // Check whether a full sync has ever completed successfully for this
474
+ // source. If not, bypass all lastmod/ETag skip logic so that every
475
+ // page is processed at least once. This handles the case where a
476
+ // previous sync was interrupted (killed mid-crawl) — the stored
477
+ // ETags/lastmods from the partial run would otherwise cause the
478
+ // remaining pages to be skipped indefinitely.
479
+ const syncCompleteKey = `sync_complete:${urlPrefix}`;
480
+ const syncCompleteValue = await database_1.DatabaseManager.getMetadataValue(dbConnection, syncCompleteKey, undefined, logger);
481
+ const forceFullSync = syncCompleteValue !== 'true';
482
+ if (forceFullSync) {
483
+ logger.info('Full sync has not yet completed for this source — forcing processing of all pages (bypassing lastmod/ETag skip)');
484
+ }
434
485
  const crawlResult = await this.contentProcessor.crawlWebsite(config.url, config, async (url, content) => {
435
486
  visitedUrls.add(url);
436
487
  logger.info(`Processing content from ${url} (${content.length} chars markdown)`);
@@ -441,21 +492,50 @@ class Doc2Vec {
441
492
  validChunkIds.add(chunk.metadata.chunk_id);
442
493
  }
443
494
  await this.processChunksForUrl(chunks, url, dbConnection, logger);
495
+ // Store the generated markdown in Postgres
496
+ if (useMarkdownStore) {
497
+ try {
498
+ await this.markdownStore.upsertMarkdown(url, config.product_name, content);
499
+ }
500
+ catch (pgError) {
501
+ logger.error(`Failed to store markdown in Postgres for ${url}:`, pgError);
502
+ }
503
+ }
444
504
  return true;
445
505
  }
446
506
  catch (error) {
447
507
  logger.error(`Error during chunking or embedding for ${url}:`, error);
448
508
  return false;
449
509
  }
450
- }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore });
510
+ }, logger, visitedUrls, { knownUrls, etagStore, lastmodStore, markdownStoreUrls, forceFullSync });
451
511
  this.recordBrokenLinks(config.url, crawlResult.brokenLinks);
452
512
  this.writeBrokenLinksReport();
453
513
  logger.info(`Found ${validChunkIds.size} valid chunks across processed pages for ${config.url}`);
454
514
  logger.section('CLEANUP');
515
+ // Remove 404 URLs from the Postgres markdown store
516
+ if (useMarkdownStore && crawlResult.notFoundUrls && crawlResult.notFoundUrls.size > 0) {
517
+ logger.info(`Removing ${crawlResult.notFoundUrls.size} not-found URLs from markdown store`);
518
+ for (const url of crawlResult.notFoundUrls) {
519
+ try {
520
+ await this.markdownStore.deleteMarkdown(url);
521
+ }
522
+ catch (pgError) {
523
+ logger.error(`Failed to delete markdown from Postgres for ${url}:`, pgError);
524
+ }
525
+ }
526
+ }
455
527
  if (crawlResult.hasNetworkErrors) {
456
528
  logger.warn('Skipping cleanup due to network errors encountered during crawling. This prevents removal of valid chunks when the site is temporarily unreachable.');
457
529
  }
458
530
  else {
531
+ // Mark this source as having completed a full sync. On subsequent
532
+ // runs, lastmod/ETag skip logic will function normally. If the
533
+ // process is killed before reaching this point, the flag stays
534
+ // unset and the next run will force-process all pages again.
535
+ if (forceFullSync) {
536
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
537
+ logger.info('Full sync completed successfully — subsequent runs will use normal caching');
538
+ }
459
539
  if (dbConnection.type === 'sqlite') {
460
540
  logger.info(`Running SQLite cleanup for ${urlPrefix}`);
461
541
  database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
@@ -502,7 +582,7 @@ class Doc2Vec {
502
582
  async processLocalDirectory(config, parentLogger) {
503
583
  const logger = parentLogger.child('process');
504
584
  logger.info(`Starting processing for local directory: ${config.path}`);
505
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
585
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
506
586
  const validChunkIds = new Set();
507
587
  const processedFiles = new Set();
508
588
  logger.section('FILE SCANNING AND EMBEDDING');
@@ -560,7 +640,7 @@ class Doc2Vec {
560
640
  async processCodeSource(config, parentLogger) {
561
641
  const logger = parentLogger.child('process');
562
642
  logger.info(`Starting processing for code source (${config.source})`);
563
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
643
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
564
644
  const validChunkIds = new Set();
565
645
  const processedFiles = new Set();
566
646
  let basePath;
@@ -692,10 +772,10 @@ class Doc2Vec {
692
772
  await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
693
773
  }
694
774
  }
695
- await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger);
775
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
696
776
  if (lastMtimeKey) {
697
777
  const nextMtime = maxObservedMtime > 0 ? maxObservedMtime : Date.now();
698
- await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger);
778
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, lastMtimeKey, `${nextMtime}`, logger, this.embeddingDimension);
699
779
  }
700
780
  }
701
781
  }
@@ -713,7 +793,7 @@ class Doc2Vec {
713
793
  const headSha = await this.getRepoHeadSha(basePath, logger);
714
794
  if (headSha) {
715
795
  const shaKey = this.buildCodeShaMetadataKey(config.repo, repoBranch);
716
- await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger);
796
+ await database_1.DatabaseManager.setMetadataValue(dbConnection, shaKey, headSha, logger, this.embeddingDimension);
717
797
  }
718
798
  }
719
799
  if (tempDir) {
@@ -876,7 +956,7 @@ class Doc2Vec {
876
956
  async processZendesk(config, parentLogger) {
877
957
  const logger = parentLogger.child('process');
878
958
  logger.info(`Starting processing for Zendesk: ${config.zendesk_subdomain}.zendesk.com`);
879
- const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger);
959
+ const dbConnection = await database_1.DatabaseManager.initDatabase(config, logger, this.embeddingDimension);
880
960
  // Initialize metadata storage
881
961
  await database_1.DatabaseManager.initDatabaseMetadata(dbConnection, logger);
882
962
  const fetchTickets = config.fetch_tickets !== false; // default true
@@ -1051,7 +1131,7 @@ class Doc2Vec {
1051
1131
  }
1052
1132
  }
1053
1133
  // Update the last run date in the database
1054
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger);
1134
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension);
1055
1135
  logger.info(`Successfully processed ${totalTickets} tickets`);
1056
1136
  }
1057
1137
  async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MarkdownStore = void 0;
4
+ const pg_1 = require("pg");
5
+ /**
6
+ * Stores generated markdown for website pages in a Postgres table.
7
+ *
8
+ * Table schema (single shared table, default name `markdown_pages`):
9
+ * url TEXT PRIMARY KEY
10
+ * product_name TEXT NOT NULL
11
+ * markdown TEXT NOT NULL
12
+ * updated_at TIMESTAMPTZ DEFAULT NOW()
13
+ *
14
+ * When a website source has `markdown_store: true`, the crawl loop uses this
15
+ * store to decide whether to force-process a page that would otherwise be
16
+ * skipped by lastmod / ETag caching. If the URL is not yet in Postgres the
17
+ * page is processed regardless of cache signals, ensuring the table is fully
18
+ * populated after the first sync. On subsequent syncs only pages with
19
+ * detected changes are updated.
20
+ */
21
+ class MarkdownStore {
22
+ constructor(config, logger) {
23
+ this.logger = logger.child('markdown-store');
24
+ this.tableName = config.table_name ?? 'markdown_pages';
25
+ const poolConfig = {};
26
+ if (config.connection_string) {
27
+ poolConfig.connectionString = config.connection_string;
28
+ }
29
+ else {
30
+ if (config.host)
31
+ poolConfig.host = config.host;
32
+ if (config.port)
33
+ poolConfig.port = config.port;
34
+ if (config.database)
35
+ poolConfig.database = config.database;
36
+ if (config.user)
37
+ poolConfig.user = config.user;
38
+ if (config.password)
39
+ poolConfig.password = config.password;
40
+ }
41
+ this.pool = new pg_1.Pool(poolConfig);
42
+ }
43
+ /**
44
+ * Create the markdown table if it doesn't already exist.
45
+ */
46
+ async init() {
47
+ const query = `
48
+ CREATE TABLE IF NOT EXISTS ${this.escapeIdentifier(this.tableName)} (
49
+ url TEXT PRIMARY KEY,
50
+ product_name TEXT NOT NULL,
51
+ markdown TEXT NOT NULL,
52
+ updated_at TIMESTAMPTZ DEFAULT NOW()
53
+ );
54
+ `;
55
+ await this.pool.query(query);
56
+ this.logger.info(`Initialized Postgres markdown store (table: ${this.tableName})`);
57
+ }
58
+ /**
59
+ * Return the set of URLs that already have markdown stored for a given URL
60
+ * prefix (e.g., "https://istio.io/latest/docs/"). This is called once
61
+ * before the crawl starts so the crawler can decide which pages need
62
+ * force-processing.
63
+ */
64
+ async getUrlsWithMarkdown(urlPrefix) {
65
+ const result = await this.pool.query(`SELECT url FROM ${this.escapeIdentifier(this.tableName)} WHERE url LIKE $1`, [urlPrefix + '%']);
66
+ return new Set(result.rows.map((row) => row.url));
67
+ }
68
+ /**
69
+ * Insert or update the markdown for a URL. Called after a page is
70
+ * successfully processed (fetched + converted to markdown).
71
+ */
72
+ async upsertMarkdown(url, productName, markdown) {
73
+ const query = `
74
+ INSERT INTO ${this.escapeIdentifier(this.tableName)} (url, product_name, markdown, updated_at)
75
+ VALUES ($1, $2, $3, NOW())
76
+ ON CONFLICT (url) DO UPDATE SET
77
+ product_name = EXCLUDED.product_name,
78
+ markdown = EXCLUDED.markdown,
79
+ updated_at = NOW();
80
+ `;
81
+ await this.pool.query(query, [url, productName, markdown]);
82
+ }
83
+ /**
84
+ * Remove a URL from the store (e.g., when a HEAD request returns 404).
85
+ */
86
+ async deleteMarkdown(url) {
87
+ await this.pool.query(`DELETE FROM ${this.escapeIdentifier(this.tableName)} WHERE url = $1`, [url]);
88
+ }
89
+ /**
90
+ * Close the connection pool. Should be called once after all sources have
91
+ * been processed.
92
+ */
93
+ async close() {
94
+ await this.pool.end();
95
+ this.logger.info('Postgres markdown store connection pool closed');
96
+ }
97
+ /**
98
+ * Escape a SQL identifier (table name) to prevent injection.
99
+ * Uses double-quoting per the SQL standard.
100
+ */
101
+ escapeIdentifier(identifier) {
102
+ return '"' + identifier.replace(/"/g, '""') + '"';
103
+ }
104
+ }
105
+ exports.MarkdownStore = MarkdownStore;
package/dist/utils.js CHANGED
@@ -69,12 +69,14 @@ 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",
@@ -43,6 +43,7 @@
43
43
  "mammoth": "^1.11.0",
44
44
  "openai": "^4.20.1",
45
45
  "pdfjs-dist": "^5.3.31",
46
+ "pg": "^8.18.0",
46
47
  "puppeteer": "^24.1.1",
47
48
  "sanitize-html": "^2.11.0",
48
49
  "sqlite-vec": "0.1.7-alpha.2",
@@ -56,6 +57,7 @@
56
57
  "@types/js-yaml": "^4.0.9",
57
58
  "@types/jsdom": "^21.1.7",
58
59
  "@types/node": "^20.10.0",
60
+ "@types/pg": "^8.16.0",
59
61
  "@types/sanitize-html": "^2.9.5",
60
62
  "@types/turndown": "^5.0.4",
61
63
  "@vitest/coverage-v8": "^4.0.18",