/** * 전국 지역별 펜션 키워드를 pgvector 에 적재한다. * npx tsx scripts/ingest-nationwide.ts * * 군산 상세 데이터셋(source='dataset')과 공존시킨다. * 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다. */ import { readFileSync } from 'node:fs'; import { createSql, toVector } from '../src/db/db'; import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; import { LocalEmbeddingProvider } from '../src/embedding/local.provider'; import { MockEmbeddingProvider } from '../src/embedding/mock.provider'; import { env } from '../src/config/env'; const SOURCE = 'nationwide'; interface Item { sido: string; region: string; regionKey: string; regionType: string; keyword: string; kind: string; intent: string; category: string; tier: string; relevance: number; } async function main() { const sql = createSql(); const embedder = env.embedding.provider === 'mock' ? new MockEmbeddingProvider() : new LocalEmbeddingProvider(); const ds = JSON.parse(readFileSync('data/nationwide-pension-keywords.json', 'utf8')); const items: Item[] = ds.items; const regions = JSON.parse(readFileSync('data/regions.json', 'utf8')).regions as Array<{ sido: string; name: string; key: string }>; console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`); // 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다. const nodes = new Map(); for (const r of regions) { const sidoKey = r.key.split('.').slice(0, -1).join('.'); nodes.set(sidoKey, r.sido); nodes.set(r.key, r.name); } nodes.set('kr', '대한민국'); for (const [key, name] of [...nodes].sort((a, b) => a[0].length - b[0].length)) { await sql`INSERT INTO region (id, path, name) VALUES (${key}, ${key}::ltree, ${name}) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`; } console.log(` 🗺 지역 노드 ${nodes.size}개 등록`); // 2) 어휘 중복 정리 — 키는 (지역, 정규화 키워드) const byKey = new Map(); let banned = 0; for (const it of items) { const canonical = canonicalizeKeyword(it.keyword); const n = normalizeKeyword(canonical); if (!n || isBanned(canonical)) { banned++; continue; } const k = `${it.regionKey}|${n}`; if (!byKey.has(k)) byKey.set(k, { item: { ...it, keyword: canonical }, norm: n }); } const uniq = [...byKey.values()]; console.log(` 어휘 중복제거 → ${uniq.length}건 (금칙어 ${banned})`); // 3) 임베딩 const t0 = Date.now(); const vecs = await embedder.embed(uniq.map((u) => u.item.keyword), 'passage'); console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`); // 4) 적재. // keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면 // 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가 // 자연히 구분되므로 문제되지 않는다. let inserted = 0, updated = 0; await sql.begin(async (tx) => { for (let i = 0; i < uniq.length; i++) { const { item, norm } = uniq[i]; const res = await tx>` INSERT INTO keyword (canonical, normalized, locale, aliases, intent, kind, category, source, industry_id, region_id, embedding) VALUES (${item.keyword}, ${norm}, 'ko-KR', ${[]}, ${item.intent}::keyword_intent, ${item.kind}, ${item.category}, ${SOURCE}, 'stay.pension', ${item.kind === 'tag' ? null : item.regionKey}, ${toVector(vecs[i])}::vector) ON CONFLICT (normalized, locale) DO UPDATE SET canonical = EXCLUDED.canonical, intent = EXCLUDED.intent, kind = EXCLUDED.kind, category = EXCLUDED.category, source = EXCLUDED.source, region_id = COALESCE(keyword.region_id, EXCLUDED.region_id), embedding = EXCLUDED.embedding, updated_at = now() RETURNING (xmax = 0) AS inserted`; res[0]?.inserted ? inserted++ : updated++; if (i % 500 === 0) process.stdout.write(`\r 적재 ${i}/${uniq.length}`); } }); console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); // 5) 이 출처 안에서만 잔여 정리 const wanted = uniq.map((u) => u.norm); const stale = await sql` DELETE FROM keyword WHERE source = ${SOURCE} AND NOT (normalized = ANY(${wanted})) RETURNING id`; console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제`); const counts = await sql>` SELECT source, count(*)::int AS n FROM keyword GROUP BY source ORDER BY n DESC`; console.log(' 📚 사전 현황: ' + counts.map((c) => `${c.source} ${c.n}`).join(' · ')); await sql.end(); } main().catch((e) => { console.error('❌', e); process.exit(1); });