전국 키워드 7,129건 pgvector 적재 + 스키 지역 오류 수정

- 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>
This commit is contained in:
hbyang 2026-09-11 17:29:47 +09:00
parent df8ce5f117
commit ac0b12b04c
7 changed files with 388 additions and 378 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령).", "note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령). ski=true 는 실제 스키장이 있는 지역 (산간이라고 다 스키장이 있는 건 아니다).",
"regions": [ "regions": [
{ {
"sido": "경기", "sido": "경기",
@ -117,7 +117,8 @@
"비발디파크", "비발디파크",
"은행나무숲", "은행나무숲",
"홍천강" "홍천강"
] ],
"ski": true
}, },
{ {
"sido": "강원", "sido": "강원",
@ -146,7 +147,8 @@
"월정사", "월정사",
"알펜시아", "알펜시아",
"양떼목장" "양떼목장"
] ],
"ski": true
}, },
{ {
"sido": "강원", "sido": "강원",
@ -160,7 +162,8 @@
"하이원리조트", "하이원리조트",
"레일바이크", "레일바이크",
"병방치스카이워크" "병방치스카이워크"
] ],
"ski": true
}, },
{ {
"sido": "강원", "sido": "강원",
@ -261,7 +264,8 @@
"태백산", "태백산",
"검룡소", "검룡소",
"365세이프타운" "365세이프타운"
] ],
"ski": true
}, },
{ {
"sido": "충북", "sido": "충북",
@ -456,7 +460,8 @@
"무주리조트", "무주리조트",
"반디랜드", "반디랜드",
"구천동계곡" "구천동계곡"
] ],
"ski": true
}, },
{ {
"sido": "전북", "sido": "전북",

View File

@ -18,7 +18,8 @@
"dataset:ingest": "tsx scripts/ingest-dataset.ts", "dataset:ingest": "tsx scripts/ingest-dataset.ts",
"dataset:purge": "tsx scripts/purge-nondataset.ts", "dataset:purge": "tsx scripts/purge-nondataset.ts",
"dataset:import-related": "tsx scripts/import-related.ts", "dataset:import-related": "tsx scripts/import-related.ts",
"dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py" "dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py",
"dataset:ingest-nationwide": "tsx scripts/ingest-nationwide.ts"
}, },
"dependencies": { "dependencies": {
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",

View File

@ -38,7 +38,7 @@ const FEATURES_COMMON = ['바베큐', '스파', '자쿠지', '수영장', '독
const SEASON_BY_TYPE = { const SEASON_BY_TYPE = {
해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'], 해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'],
: ['여름휴가', '일출'], : ['여름휴가', '일출'],
산간: ['겨울', '단풍', '스키', '눈꽃'], 산간: ['겨울', '단풍', '눈꽃'],
계곡: ['여름휴가', '물놀이', '단풍'], 계곡: ['여름휴가', '물놀이', '단풍'],
호수: ['여름휴가', '단풍', '벚꽃'], 호수: ['여름휴가', '단풍', '벚꽃'],
강변: ['벚꽃', '단풍'], 강변: ['벚꽃', '단풍'],
@ -69,7 +69,12 @@ const uniq = (a) => [...new Set(a)];
for (const r of regions) { for (const r of regions) {
const R = r.name; const R = r.name;
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]); const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
const seasons = uniq([...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []), ...SEASON_COMMON]); // '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다.
const seasons = uniq([
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
...SEASON_COMMON,
]);
// T1 코어 — 주력 후보. // T1 코어 — 주력 후보.
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다. // 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
@ -131,10 +136,12 @@ for (const r of regions) {
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 }); add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
} }
// 광역 단위 롤업 // 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
const sidoKey = {};
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
const sidoList = uniq(regions.map((x) => x.sido)); const sidoList = uniq(regions.map((x) => x.sido));
for (const sido of sidoList) { for (const sido of sidoList) {
const pseudo = { sido, name: sido, key: `rollup.${sido}`, type: [] }; const pseudo = { sido, name: sido, key: sidoKey[sido], type: [] };
for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션']) for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션'])
add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 }); add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 });
for (const m of ['추천', '예약', '가격', '후기']) for (const m of ['추천', '예약', '가격', '후기'])

View File

@ -0,0 +1,105 @@
/**
* 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); });

View File

@ -15,7 +15,7 @@ const RRF_K = 20;
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다 const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. // 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
const MATCH_SOURCES = ['dataset', 'manual']; const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
interface Hit { interface Hit {
id: string; canonical: string; intent: string; kind: string; id: string; canonical: string; intent: string; kind: string;