② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
- 인원: 2인·3인 추가 (4/6/10/20인만 있어 기준 2인 업체가 노릴 키워드가 없었다)
- 단독 명사형: 군산 독채 / 스테이 / 풀빌라 (조합형만 있어 단독 검색을 놓쳤다)
- 분위기 11종(감성·조용한·사진찍기 좋은·인생샷 …), 여행형태 9종(뚜벅이·1박2일 …)
- scripts/import-related.ts — 검색광고 키워드도구 내려받기 병합 (파일 임포터)
③ 사전 오염 정리
- source='llm' 27건 삭제 (강남 미용실·해운대 한식당이 군산 펜션 사전에 있었다)
- 매칭 후보를 업체 업종으로 한정 + source IN ('dataset','manual') 만 조회
- 적재 시 데이터셋에서 빠진 행 삭제 — upsert 만 하면 재빌드마다 누적된다 (974 → 1072)
④ 고객 언어 레인 (w=0.9)
- hashtags + reviewSignals(원문 아닌 빈도 집계) 를 별도 레인으로
- 사업자 표현보다 검색어에 가까우므로 유형 다음으로 높게 잡음
- 스테이머뭄 데이터는 아직 없음 — 인스타는 로그인 월이라 스크래퍼가 채워야 함
⑤ 레인 토큰 중복 제거
- '신흥동' 과 '신흥동 일본식가옥' 이 별개 원소라 같은 낱말이 두 번 실렸다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
5.0 KiB
TypeScript
104 lines
5.0 KiB
TypeScript
/**
|
|
* 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<string, { item: Item; aliases: string[] }>();
|
|
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<Array<{ inserted: boolean }>>`
|
|
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) 데이터셋에서 빠진 행 정리.
|
|
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
|
|
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
|
|
const wanted = uniq.map(([norm]) => norm);
|
|
const stale = await sql<Array<{ canonical: string }>>`
|
|
DELETE FROM keyword
|
|
WHERE source = 'dataset' AND locale = 'ko-KR' AND NOT (normalized = ANY(${wanted}))
|
|
RETURNING canonical`;
|
|
console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제` +
|
|
(stale.length ? ` (예: ${stale.slice(0, 4).map((r) => r.canonical).join(', ')})` : ''));
|
|
|
|
const [{ n: total }] = await sql<Array<{ n: number }>>`
|
|
SELECT count(*)::int AS n FROM keyword WHERE source = 'dataset'`;
|
|
console.log(` 📚 사전 현재 ${total}건`);
|
|
|
|
// 5) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
|
|
const near = await sql<Array<{ a: string; b: string; sim: number }>>`
|
|
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); });
|