175 lines
7.6 KiB
JavaScript
175 lines
7.6 KiB
JavaScript
// One-time regulation-catalog ingestion into Qdrant.
|
|
//
|
|
// Reads a LOCAL, git-ignored catalog JSONL (one embedding chunk per line),
|
|
// embeds each chunk's `embed_text` via an OpenAI-compatible endpoint, and
|
|
// upserts the vectors + metadata into a Qdrant collection. Idempotent: re-runs
|
|
// upsert the same deterministic point ids.
|
|
//
|
|
// The catalog file contains licensed authoritative text and must stay OUTSIDE
|
|
// the repository. This script is committed; the catalog is not.
|
|
//
|
|
// Usage:
|
|
// node server/regmap/ingest-catalog.mjs [catalog.jsonl]
|
|
//
|
|
// Required env: ASSESSOR_QDRANT_URL, ASSESSOR_EMBEDDING_BASE_URL,
|
|
// ASSESSOR_EMBEDDING_MODEL, ASSESSOR_EMBEDDING_DIMENSIONS
|
|
// Optional env: ASSESSOR_QDRANT_API_KEY, ASSESSOR_QDRANT_COLLECTION,
|
|
// ASSESSOR_EMBEDDING_API_KEY, ASSESSOR_EMBEDDING_PASSAGE_PREFIX,
|
|
// ASSESSOR_REGMAP_CATALOG_VERSION, ASSESSOR_REGMAP_CATALOG_FILE,
|
|
// ASSESSOR_REGMAP_INGEST_BATCH (default 32)
|
|
//
|
|
// Prints counts only — never catalog text, vectors or payloads.
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const env = process.env;
|
|
|
|
function required(name) {
|
|
const value = env[name]?.trim();
|
|
if (!value) throw new Error(`${name} ist erforderlich.`);
|
|
return value;
|
|
}
|
|
function stripTrailingSlash(url) {
|
|
return url.replace(/\/+$/, '');
|
|
}
|
|
|
|
const catalogFile = (process.argv[2] ?? env.ASSESSOR_REGMAP_CATALOG_FILE ?? '').trim();
|
|
if (!catalogFile) throw new Error('Katalogdatei fehlt: als Argument oder über ASSESSOR_REGMAP_CATALOG_FILE angeben.');
|
|
|
|
const qdrantUrl = stripTrailingSlash(required('ASSESSOR_QDRANT_URL'));
|
|
const qdrantApiKey = env.ASSESSOR_QDRANT_API_KEY?.trim() || undefined;
|
|
const collection = env.ASSESSOR_QDRANT_COLLECTION?.trim() || 'regulation_catalog_v1';
|
|
const embeddingBaseUrl = stripTrailingSlash(required('ASSESSOR_EMBEDDING_BASE_URL'));
|
|
const embeddingModel = required('ASSESSOR_EMBEDDING_MODEL');
|
|
const embeddingApiKey = env.ASSESSOR_EMBEDDING_API_KEY?.trim() || undefined;
|
|
const dimensions = Number(required('ASSESSOR_EMBEDDING_DIMENSIONS'));
|
|
if (!Number.isInteger(dimensions) || dimensions < 8) throw new Error('ASSESSOR_EMBEDDING_DIMENSIONS muss eine positive Ganzzahl sein.');
|
|
const passagePrefix = env.ASSESSOR_EMBEDDING_PASSAGE_PREFIX ?? '';
|
|
const expectedCatalogVersion = env.ASSESSOR_REGMAP_CATALOG_VERSION?.trim() || undefined;
|
|
const batchSize = Math.max(1, Math.min(256, Number(env.ASSESSOR_REGMAP_INGEST_BATCH ?? 32) || 32));
|
|
|
|
// Deterministic UUID (v5-like) from a stable chunk id, so re-runs upsert in place.
|
|
function pointId(chunkId) {
|
|
const hex = createHash('sha256').update(chunkId).digest('hex').slice(0, 32).split('');
|
|
hex[12] = '5';
|
|
hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
|
|
const h = hex.join('');
|
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
}
|
|
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
// Retry only transient connection-level failures (a thrown `fetch failed`),
|
|
// not HTTP status codes. Local Qdrant/embedding endpoints on WSL occasionally
|
|
// reset a connection mid-run; a fresh connection then succeeds.
|
|
async function fetchRetry(url, options, attempts = 12) {
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
try {
|
|
return await fetch(url, options);
|
|
} catch (error) {
|
|
if (attempt >= attempts) throw error;
|
|
if (attempt === 1) process.stdout.write(`\n (endpoint hiccup, retrying up to ${attempts - 1}x)`);
|
|
await sleep(Math.min(3000, 750 * attempt));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function embed(texts) {
|
|
const response = await fetch(`${embeddingBaseUrl}/embeddings`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...(embeddingApiKey ? { Authorization: `Bearer ${embeddingApiKey}` } : {}) },
|
|
body: JSON.stringify({ model: embeddingModel, input: texts.map(t => `${passagePrefix}${t}`) })
|
|
});
|
|
if (!response.ok) throw new Error(`Embedding-Dienst antwortete mit HTTP ${response.status}.`);
|
|
const payload = await response.json();
|
|
const rows = Array.isArray(payload.data) ? payload.data : [];
|
|
if (rows.length !== texts.length) throw new Error('Embedding-Dienst lieferte eine unerwartete Anzahl an Vektoren.');
|
|
return rows.map(row => {
|
|
const vector = Array.isArray(row.embedding) ? row.embedding : [];
|
|
if (vector.length !== dimensions) throw new Error(`Embedding lieferte ${vector.length} Dimensionen, erwartet ${dimensions}.`);
|
|
return vector;
|
|
});
|
|
}
|
|
|
|
async function qdrant(path, method, body) {
|
|
return fetchRetry(`${qdrantUrl}${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...(qdrantApiKey ? { 'api-key': qdrantApiKey } : {}) },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
async function ensureCollection() {
|
|
const info = await qdrant(`/collections/${encodeURIComponent(collection)}`, 'GET');
|
|
if (info.ok) {
|
|
const data = await info.json().catch(() => null);
|
|
const size = data?.result?.config?.params?.vectors?.size;
|
|
if (size !== undefined && size !== dimensions) {
|
|
throw new Error(`Bestehende Collection hat Vektorgröße ${size}, erwartet ${dimensions}.`);
|
|
}
|
|
return;
|
|
}
|
|
if (info.status !== 404) throw new Error(`Qdrant antwortete mit HTTP ${info.status}.`);
|
|
const created = await qdrant(`/collections/${encodeURIComponent(collection)}`, 'PUT', { vectors: { size: dimensions, distance: 'Cosine' } });
|
|
if (!created.ok) throw new Error(`Collection konnte nicht angelegt werden (HTTP ${created.status}).`);
|
|
}
|
|
|
|
const PAYLOAD_FIELDS = [
|
|
'unit_id', 'unit_version', 'regulation_id', 'regulation_title',
|
|
'control_id', 'control_title', 'language', 'catalog_version',
|
|
'chunk_id', 'chunk_text', 'authoritative_text', 'source_uri'
|
|
];
|
|
|
|
function toPayload(row) {
|
|
const payload = {};
|
|
for (const field of PAYLOAD_FIELDS) {
|
|
if (row[field] !== undefined) payload[field] = row[field];
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function main() {
|
|
const raw = await readFile(catalogFile, 'utf8');
|
|
const lines = raw.split(/\r?\n/).filter(line => line.trim());
|
|
const rows = [];
|
|
const units = new Set();
|
|
lines.forEach((line, index) => {
|
|
let row;
|
|
try {
|
|
row = JSON.parse(line);
|
|
} catch {
|
|
throw new Error(`Zeile ${index + 1} ist kein gültiges JSON.`);
|
|
}
|
|
if (!row.unit_id || !row.chunk_id || !row.embed_text?.trim()) {
|
|
throw new Error(`Zeile ${index + 1} fehlen Pflichtfelder (unit_id, chunk_id, embed_text).`);
|
|
}
|
|
if (expectedCatalogVersion && row.catalog_version !== expectedCatalogVersion) {
|
|
throw new Error(`Zeile ${index + 1} hat catalog_version, die nicht ${expectedCatalogVersion} entspricht.`);
|
|
}
|
|
units.add(row.unit_id);
|
|
rows.push(row);
|
|
});
|
|
if (rows.length === 0) throw new Error('Die Katalogdatei enthält keine Zeilen.');
|
|
|
|
await ensureCollection();
|
|
|
|
let upserted = 0;
|
|
for (let start = 0; start < rows.length; start += batchSize) {
|
|
const batch = rows.slice(start, start + batchSize);
|
|
const vectors = await embed(batch.map(row => row.embed_text));
|
|
const points = batch.map((row, i) => ({ id: pointId(row.chunk_id), vector: vectors[i], payload: toPayload(row) }));
|
|
const response = await qdrant(`/collections/${encodeURIComponent(collection)}/points?wait=true`, 'PUT', { points });
|
|
if (!response.ok) throw new Error(`Upsert schlug fehl (HTTP ${response.status}).`);
|
|
upserted += points.length;
|
|
process.stdout.write(` upserted ${upserted}/${rows.length}\r`);
|
|
}
|
|
|
|
console.log(`\nIngestion abgeschlossen: ${upserted} Chunk(s) aus ${units.size} Unit(s) in Collection "${collection}" (Dim ${dimensions}).`);
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error('Ingestion fehlgeschlagen:', error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|