- scripts/ingest-nationwide.ts — 지역 계층 66개 노드 선행 등록 후 적재 source='nationwide' 로 군산 상세 데이터셋(dataset)과 공존 태그는 지역 중립이므로 region_id NULL - 광역 롤업 키를 ASCII 로 (ltree 라벨은 한글 불가: rollup.경기 → kr.gyeonggi) - MATCH_SOURCES 에 nationwide 추가 데이터 오류 수정: '산간'이라고 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생성돼 있었다. regions.json 에 ski 플래그를 두고 실제 스키장 보유 5개 지역(평창·정선·홍천·태백·무주)에만 전개한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
106 lines
4.9 KiB
TypeScript
106 lines
4.9 KiB
TypeScript
/**
|
|
* 전국 지역별 펜션 키워드를 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<string, string>();
|
|
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<string, { item: Item; norm: string }>();
|
|
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<Array<{ inserted: boolean }>>`
|
|
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<Array<{ source: string; n: number }>>`
|
|
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); });
|