o2o-site-ontology/scripts/ingest-dataset.ts
hbyang edde23f15e 고정 데이터셋 1,000건 적재 + 로컬 임베딩 + 매칭 콘솔
정책 변경: 주기 수집 없이 고정 데이터셋을 1회 적재한다.

- data/gunsan-pension-keywords.json — "군산 펜션" 키워드·태그 1,000건
  실제 군산 지명·관광지·숙박 시설 어휘 × 로컬 검색 패턴으로 작성
- 임베딩을 LlmProvider 에서 EmbeddingProvider 로 분리
  (mock | local:multilingual-e5-small | openai), e5 의 query/passage 비대칭 반영
- vector(1536) → vector(384) 마이그레이션, keyword 에 source/kind/category 추가
- scripts/ingest-dataset.ts — 어휘 중복만 자동 병합, 벡터 근접쌍은 검토 목록만 출력
- POST /v1/match — 업체명(띄어쓰기 무관) 또는 문장 → 사전에서 매칭
  업체는 프로필 전체를 질의문으로 조립해 임베딩
- GET /demo — 매칭 콘솔 (public/demo.html)

실측으로 코사인 자동 병합 임계값을 0.92 → 0.99 로 정정.
짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본이라 0.92 는 오병합을 부른다.

적재 결과: 1,000건 → 어휘 중복 27 병합, 금칙어 2 차단 → 971건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:11:45 +09:00

89 lines
4.2 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) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
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); });