/** * data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. * npx tsx scripts/ingest-dataset.ts * * 정책: 주기 수집 없음. 고정 데이터셋 1회 적재. * 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고, * 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조) */ import { readFileSync } from 'node:fs'; import { createSql, toVector } from '../src/db/db'; import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize'; import { LocalEmbeddingProvider } from '../src/embedding/local.provider'; import { MockEmbeddingProvider } from '../src/embedding/mock.provider'; import { env } from '../src/config/env'; type Item = { keyword: string; intent: string; kind: string; category: string; relevance: number }; async function main() { const sql = createSql(); const embedder = env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider(); const raw = JSON.parse(readFileSync('data/gunsan-pension-keywords.json', 'utf8')); const items: Item[] = raw.items; console.log(`📦 데이터셋 ${items.length}건 · 임베딩 ${embedder.name}`); // 1) 파일 내 어휘 중복 정리 const byNorm = new Map(); let banned = 0; for (const it of items) { const canonical = canonicalizeKeyword(it.keyword); const norm = normalizeKeyword(it.keyword); if (!norm || isBanned(canonical)) { banned++; continue; } const hit = byNorm.get(norm); if (hit) { if (!hit.aliases.includes(canonical) && hit.item.keyword !== canonical) hit.aliases.push(canonical); if (it.relevance > hit.item.relevance) hit.item = it; } else { byNorm.set(norm, { item: { ...it, keyword: canonical }, aliases: [] }); } } const uniq = [...byNorm.entries()]; console.log(` 어휘 중복제거 → ${uniq.length}건 (병합 ${items.length - uniq.length - banned}, 금칙어 ${banned})`); // 2) 임베딩 (배치) const t0 = Date.now(); const vecs = await embedder.embed(uniq.map(([, v]) => v.item.keyword), 'passage'); console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`); // 3) 적재 const region = 'kr.jeonbuk.gunsan'; const industry = 'stay.pension'; let inserted = 0, updated = 0; await sql.begin(async (tx) => { for (let i = 0; i < uniq.length; i++) { const [norm, v] = uniq[i]; const res = await tx>` INSERT INTO keyword (canonical, normalized, locale, aliases, intent, kind, category, source, industry_id, region_id, embedding) VALUES (${v.item.keyword}, ${norm}, 'ko-KR', ${v.aliases}, ${v.item.intent}::keyword_intent, ${v.item.kind}, ${v.item.category}, 'dataset', ${industry}, ${region}, ${toVector(vecs[i])}::vector) ON CONFLICT (normalized, locale) DO UPDATE SET canonical = EXCLUDED.canonical, aliases = EXCLUDED.aliases, intent = EXCLUDED.intent, kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source, embedding = EXCLUDED.embedding, updated_at = now() RETURNING (xmax = 0) AS inserted`; res[0]?.inserted ? inserted++ : updated++; if (i % 100 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`); } }); console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); // 4) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만 const near = await sql>` SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim FROM keyword k1 JOIN keyword k2 ON k1.id < k2.id AND k1.embedding <=> k2.embedding < 0.02 WHERE k1.source = 'dataset' AND k2.source = 'dataset' ORDER BY sim DESC LIMIT 15`; console.log(`\n🔍 벡터 근접쌍 검토 목록 (cos ≥ 0.98, 자동 병합 안 함) — 상위 ${near.length}`); for (const n of near) console.log(` ${Number(n.sim).toFixed(4)} ${n.a} ↔ ${n.b}`); await sql.end(); } main().catch((e) => { console.error('❌', e); process.exit(1); });