Files
crm-ai/bridge/src/kb-indexer.js
T

181 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { parseFrontmatter, splitChunks, createParentChildChunks } = require('./kb-chunker');
// 新 bridge 独立 KB 索引器:读本地 kb/*.md,生成 parent/child + embedding,
// staging 表校验后原子切换 kb_docs,旧表保留为 kb_docs_v<timestamp>。
function createKbIndexer({ pool, embeddingClient, kbDir, dim = 1024, logger = console }) {
const DIM = Number(dim) || 1024;
const KB_DIR = kbDir || path.resolve(__dirname, '..', 'kb');
async function ensureBase() {
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
await pool.query(`
CREATE TABLE IF NOT EXISTS kb_sync_runs (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_hash TEXT,
parent_count INT,
child_count INT,
backup_table TEXT,
note TEXT
)
`);
}
function listFiles() {
return fs.readdirSync(KB_DIR)
.filter((f) => f.endsWith('.md') && /^\d+/.test(f))
.sort();
}
function sourceHash(files) {
const h = crypto.createHash('sha256');
for (const f of files) {
h.update(f);
h.update(fs.readFileSync(path.join(KB_DIR, f)));
}
return h.digest('hex');
}
async function createStaging() {
await pool.query('DROP TABLE IF EXISTS kb_docs_staging CASCADE');
await pool.query(`
CREATE TABLE kb_docs_staging (
id SERIAL PRIMARY KEY,
source_file TEXT,
title TEXT,
category TEXT,
section TEXT,
question TEXT,
answer TEXT,
keywords TEXT[],
source_urls TEXT[],
text_vec vector(${DIM}),
parent_id INTEGER,
chunk_type TEXT DEFAULT 'parent',
keywords_text TEXT,
text_tsv tsvector
)
`);
}
async function insertDoc(table, doc, parentId) {
const text = [doc.title, doc.category, doc.section, doc.question, doc.answer, (doc.keywords || []).join(' ')].filter(Boolean).join(' ');
const r = await pool.query(
`INSERT INTO ${table}
(source_file,title,category,section,question,answer,keywords,source_urls,text_vec,parent_id,chunk_type,keywords_text,text_tsv)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::vector,$10,$11,$12,to_tsvector('english',$13))
RETURNING id`,
[doc.source_file, doc.title, doc.category, doc.section, doc.question, doc.answer,
doc.keywords || [], doc.source_urls || [], embeddingClient.vecStr(doc.vec), parentId || null,
doc.chunk_type, (doc.keywords || []).join(' '), text]
);
return r.rows[0].id;
}
async function indexFile(table, file) {
const raw = fs.readFileSync(path.join(KB_DIR, file), 'utf8');
const { meta, body } = parseFrontmatter(raw);
const chunks = splitChunks(body);
let parents = 0;
let children = 0;
for (const chunk of chunks) {
const { parent, children: childChunks } = createParentChildChunks(chunk, meta, file);
parent.vec = await embeddingClient.embedQuery(parent.embText);
const parentId = await insertDoc(table, parent, null);
parents += 1;
for (const child of childChunks) {
child.vec = await embeddingClient.embedQuery(child.embText);
await insertDoc(table, child, parentId);
children += 1;
}
}
return { parents, children };
}
async function createStagingIndexes() {
await pool.query('CREATE INDEX kb_docs_staging_vec_idx ON kb_docs_staging USING hnsw (text_vec vector_cosine_ops)');
await pool.query('CREATE INDEX kb_docs_staging_tsv_idx ON kb_docs_staging USING GIN (text_tsv)');
await pool.query('CREATE INDEX kb_docs_staging_parent_idx ON kb_docs_staging (parent_id) WHERE parent_id IS NOT NULL');
}
async function atomicSwitch() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const has = await client.query("SELECT to_regclass('public.kb_docs') IS NOT NULL AS exists");
let backup = null;
if (has.rows[0].exists) {
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
backup = `kb_docs_v${stamp}`;
let suffix = 0;
while ((await client.query('SELECT to_regclass($1) IS NOT NULL AS exists', [`public.${backup}`])).rows[0].exists) {
suffix += 1;
backup = `kb_docs_v${stamp}_${suffix}`;
}
await client.query(`ALTER TABLE kb_docs RENAME TO ${backup}`);
await client.query(`ALTER INDEX IF EXISTS kb_docs_vec_idx RENAME TO ${backup}_vec_idx`);
await client.query(`ALTER INDEX IF EXISTS kb_docs_tsv_idx RENAME TO ${backup}_tsv_idx`);
await client.query(`ALTER INDEX IF EXISTS kb_docs_parent_idx RENAME TO ${backup}_parent_idx`);
}
await client.query('ALTER TABLE kb_docs_staging RENAME TO kb_docs');
await client.query('ALTER INDEX IF EXISTS kb_docs_staging_vec_idx RENAME TO kb_docs_vec_idx');
await client.query('ALTER INDEX IF EXISTS kb_docs_staging_tsv_idx RENAME TO kb_docs_tsv_idx');
await client.query('ALTER INDEX IF EXISTS kb_docs_staging_parent_idx RENAME TO kb_docs_parent_idx');
await client.query('COMMIT');
return backup;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function reindex({ note = 'kb sync' } = {}) {
const lockClient = await pool.connect();
const lock = await lockClient.query("SELECT pg_try_advisory_lock(hashtext('bridge_kb_sync')) AS locked");
if (!lock.rows[0].locked) {
lockClient.release();
throw new Error('another KB sync is running');
}
try {
await ensureBase();
const files = listFiles();
if (!files.length) throw new Error(`no KB markdown files in ${KB_DIR}`);
const hash = sourceHash(files);
await createStaging();
let parents = 0;
let children = 0;
for (const file of files) {
const res = await indexFile('kb_docs_staging', file);
parents += res.parents;
children += res.children;
}
if (parents === 0) throw new Error('indexed 0 parent chunks');
await createStagingIndexes();
const backup = await atomicSwitch();
await pool.query(
'INSERT INTO kb_sync_runs (source_hash,parent_count,child_count,backup_table,note) VALUES ($1,$2,$3,$4,$5)',
[hash, parents, children, backup, note]
);
logger.log(JSON.stringify({ ts: new Date().toISOString(), kb_sync: 'done', parents, children, backup, files: files.length }));
return { parents, children, backup, sourceHash: hash, files: files.length };
} catch (err) {
await pool.query('DROP TABLE IF EXISTS kb_docs_staging CASCADE').catch(() => {});
throw err;
} finally {
await lockClient.query("SELECT pg_advisory_unlock(hashtext('bridge_kb_sync'))").catch(() => {});
lockClient.release();
}
}
return { reindex, listFiles, sourceHash, KB_DIR };
}
module.exports = { createKbIndexer };