feat: add online FAQ/KB sync without old bridge

This commit is contained in:
wangliang
2026-10-23 11:29:43 +08:00
parent f526696f93
commit 81b49c9350
24 changed files with 1793 additions and 5 deletions
+180
View File
@@ -0,0 +1,180 @@
'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 };