스테이머뭄 매칭 품질 개선 4건

② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
   - 인원: 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>
This commit is contained in:
hbyang 2026-09-11 14:09:36 +09:00
parent f7485aab32
commit 25f2cc871d
10 changed files with 958 additions and 696 deletions

View File

@ -86,7 +86,20 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다.
재적재는 `ON CONFLICT (normalized, locale) DO UPDATE` 라 몇 번을 돌려도 971건을 유지한다.
적재는 upsert 이고, **데이터셋에서 빠진 행은 같이 지운다** — 안 그러면 재빌드할 때마다
이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다).
| 명령 | 용도 |
|---|---|
| `npm run dataset:build` | 데이터셋 생성 |
| `npm run dataset:ingest` | 임베딩 + 적재 + 잔여 정리 |
| `npm run dataset:purge` | 큐레이션 외 출처(`llm` 등) 제거. `--apply` 로 실행 |
| `npm run dataset:import-related` | 검색광고 키워드도구 내려받기(CSV/JSON) 병합. `--apply` 로 실행 |
`dataset:import-related` 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는
계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아
`data/related-keywords.sample.csv` 형식으로 두면 그대로 병합된다 —
나중에 API 를 붙여도 이 임포터를 재사용한다.
## 임베딩 임계값 — 실측으로 정정한 부분
@ -202,6 +215,34 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
#### 레인 구성
| 레인 | 가중치 | 출처 | 비고 |
|---|---|---|---|
| 유형 | 1.0 | 지역 + 업종 + 숙소유형 | 앵커. 주력 키워드가 여기서 나온다 |
| **고객언어** | **0.9** | `reviewSignals` 빈출어 + `hashtags` | 사업자 표현보다 검색어에 가깝다 |
| 위치 | 0.7 | 권역 + 행정동 + 인근 랜드마크 | |
| 동반자 | 0.6 | `audiences` | |
| 시설 | 0.6 | 정규화된 `amenities` | |
레인 텍스트는 **낱말 단위로 중복을 제거**한다. 문자열 단위 Set 만으로는 `신흥동`
`신흥동 일본식가옥` 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다.
후보 풀은 **업체 업종으로 한정**하고 `source IN ('dataset','manual')` 만 본다.
사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다.
#### 고객 언어 신호
리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다.
```json
"hashtags": ["#군산감성숙소", "#뚜벅이여행"],
"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }]
```
빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다.
현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다.
#### 사실 기반 필터 — 벡터가 못 거르는 것
임베딩은 "비슷함"만 알지 "최대 4인 < 단체" 모른다. 그래서 코드 조건으로 배제한다.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
군산독채펜션,210,1830,중간
군산감성숙소,90,760,낮음
군산2인펜션,40,310,낮음
군산뚜벅이여행,30,240,낮음
말랭이마을숙소,10,90,낮음
1 # 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
2 # (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
3 relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
4 군산독채펜션,210,1830,중간
5 군산감성숙소,90,760,낮음
6 군산2인펜션,40,310,낮음
7 군산뚜벅이여행,30,240,낮음
8 말랭이마을숙소,10,90,낮음

View File

@ -15,7 +15,9 @@
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
"smoke": "tsx scripts/smoke.ts",
"dataset:build": "node scripts/build-dataset.mjs",
"dataset:ingest": "tsx scripts/ingest-dataset.ts"
"dataset:ingest": "tsx scripts/ingest-dataset.ts",
"dataset:purge": "tsx scripts/purge-nondataset.ts",
"dataset:import-related": "tsx scripts/import-related.ts"
},
"dependencies": {
"@huggingface/transformers": "^4.2.0",

View File

@ -37,7 +37,15 @@ const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약'
// 동반자
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
'회사', '태교여행', '혼자', '여자끼리', '4인', '6인', '10인', '20인'];
'회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인'];
// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어
const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집',
'깔끔한', '신축', '프라이빗한', '혼자 있기 좋은'];
// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다
const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행',
'워케이션', '무박', '한달살기'];
// 시설·특징
const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
@ -75,6 +83,9 @@ for (const s of STAY_CORE) for (const m of INTENT_CORE)
add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 });
for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 });
for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 });
// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다
for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박'])
add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 });
// T2 — 섬·권역 × 숙소유형
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
@ -118,6 +129,19 @@ for (const o of OCCASION) {
add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 });
}
// T5.5 — 분위기·여행형태
for (const v of VIBE) {
add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 });
add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 });
add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 });
}
for (const t of TRAVEL) {
add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 });
add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 });
}
for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3))
add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 });
// T6 — 관광지 앵커
for (const sp of SPOTS) {
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
@ -129,7 +153,8 @@ for (const sp of SPOTS) {
// T7 — 태그 (칩 UI 용 짧은 패싯)
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
'오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능',
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷'];
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷',
...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심'];
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
// T8 — 질문형 (AEO)

90
scripts/import-related.ts Normal file
View File

@ -0,0 +1,90 @@
/**
* · .
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
*
* (CSV) JSON.
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
*
* API 이유: 검색광고 API ·HMAC
* . ,
* API .
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
const DATASET = 'data/gunsan-pension-keywords.json';
interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null }
function parse(path: string): Related[] {
const raw = readFileSync(path, 'utf8');
if (path.endsWith('.json')) {
return (JSON.parse(raw) as any[]).map(toRelated);
}
const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#'));
const head = lines.shift()!.split(',').map((h) => h.trim());
return lines.map((line) => {
const cells = line.split(',').map((c) => c.trim());
const o: Record<string, string> = {};
head.forEach((h, i) => (o[h] = cells[i] ?? ''));
return toRelated(o);
});
}
function toRelated(o: any): Related {
const num = (v: unknown) => {
const n = Number(String(v ?? '').replace(/[^0-9]/g, ''));
return Number.isFinite(n) ? n : 0;
};
return {
keyword: String(o.relKeyword ?? o.keyword ?? '').trim(),
volumePc: num(o.monthlyPcQcCnt),
volumeMobile: num(o.monthlyMobileQcCnt),
competition: o.compIdx ? String(o.compIdx).trim() : null,
};
}
function main() {
const file = process.argv[2];
const apply = process.argv.includes('--apply');
if (!file) { console.error('사용법: tsx scripts/import-related.ts <csv|json> [--apply]'); process.exit(1); }
const ds = JSON.parse(readFileSync(DATASET, 'utf8'));
const existing = new Map<string, any>(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i]));
const rows = parse(file).filter((r) => r.keyword);
let added = 0, enriched = 0, skipped = 0;
const newItems: any[] = [];
for (const r of rows) {
const canonical = canonicalizeKeyword(r.keyword);
const norm = normalizeKeyword(canonical);
if (!norm || isBanned(canonical)) { skipped++; continue; }
const volume = r.volumePc + r.volumeMobile;
const hit = existing.get(norm);
if (hit) {
hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad';
enriched++;
} else {
const item = {
keyword: canonical, intent: 'local', kind: 'keyword', category: '연관',
relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad',
};
newItems.push(item); existing.set(norm, item); added++;
}
}
console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`);
if (newItems.length) {
console.log('\n신규 예시');
for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`);
}
if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; }
ds.items = [...ds.items, ...newItems];
ds.count = ds.items.length;
writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n');
console.log(`\n✅ ${DATASET}${ds.count}`);
}
main();

View File

@ -72,7 +72,22 @@ async function main() {
});
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
// 4) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만
// 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

View File

@ -0,0 +1,35 @@
/**
* .
* npx tsx scripts/purge-nondataset.ts [--apply]
*
* (keyword) . generate
* source='llm' .
*/
import { createSql } from '../src/db/db';
async function main() {
const apply = process.argv.includes('--apply');
const sql = createSql();
const rows = await sql<Array<{ source: string; n: number; sample: string[] }>>`
SELECT source, count(*)::int AS n, (array_agg(canonical ORDER BY canonical))[1:6] AS sample
FROM keyword GROUP BY source ORDER BY n DESC`;
console.log('출처별 현황');
for (const r of rows) console.log(` ${r.source.padEnd(10)} ${String(r.n).padStart(5)} ${r.sample.join(', ')}`);
const doomed = await sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE source NOT IN ('dataset', 'manual')`;
const n = doomed[0]?.n ?? 0;
if (n === 0) { console.log('\n정리 대상 없음'); await sql.end(); return; }
if (!apply) {
console.log(`\n정리 대상 ${n}건. 실제로 지우려면 --apply 를 붙일 것.`);
await sql.end();
return;
}
const del = await sql`DELETE FROM keyword WHERE source NOT IN ('dataset', 'manual') RETURNING id`;
console.log(`\n✅ ${del.length}건 삭제 (merchant_keyword 는 CASCADE)`);
await sql.end();
}
main().catch((e) => { console.error('❌', e); process.exit(1); });

View File

@ -21,6 +21,8 @@ export interface MerchantFacts {
nearby: string[];
amenities: Set<string>; // 정규화된 보유 시설
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
signals: string[];
}
export type AreaGroup = '해안·도서' | '원도심' | '시내';
@ -133,8 +135,22 @@ const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|
*/
export function buildLanes(f: MerchantFacts): Lane[] {
const lanes: Lane[] = [];
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
const text = [...new Set(parts.filter(Boolean) as string[])].join(' ').replace(/\s+/g, ' ').trim();
const seen = new Set<string>();
const words: string[] = [];
for (const part of parts) {
if (!part) continue;
for (const w of String(part).split(/\s+/)) {
const k = w.trim();
if (!k || seen.has(k)) continue;
seen.add(k);
words.push(k);
}
}
const text = words.join(' ');
if (text) lanes.push({ key, label, weight, text });
};
@ -153,6 +169,8 @@ export function buildLanes(f: MerchantFacts): Lane[] {
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
push('amenity', '시설', 0.6, amenityWords);
// 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다
push('signal', '고객언어', 0.9, f.signals.slice(0, 10));
return lanes;
}

View File

@ -14,6 +14,8 @@ import {
const RRF_K = 20;
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
const MATCH_SOURCES = ['dataset', 'manual'];
interface Hit {
id: string; canonical: string; intent: string; kind: string;
@ -47,9 +49,11 @@ export class MatchService {
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
// 레인별 검색
// 레인별 검색.
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
const perLane = await Promise.all(
vectors.map((v) => this.laneSearch(v, LANE_DEPTH)),
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
);
// 가중 RRF 융합
@ -137,13 +141,17 @@ export class MatchService {
};
}
private async laneSearch(embedding: number[], limit: number): Promise<Hit[]> {
private async laneSearch(
embedding: number[], limit: number, industryId: string | null,
): Promise<Hit[]> {
const vec = toVector(embedding);
const rows = await this.sql<Hit[]>`
SELECT id, canonical, intent, kind, category, aliases,
1 - (embedding <=> ${vec}::vector) AS score
FROM keyword
WHERE embedding IS NOT NULL
AND source = ANY(${MATCH_SOURCES})
AND (${industryId}::text IS NULL OR industry_id IS NULL OR industry_id = ${industryId})
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}`;
return rows.map((r) => ({ ...r, score: Number(r.score) }));
@ -180,7 +188,8 @@ export class MatchService {
async dictionarySize() {
const [row] = await this.sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`;
SELECT count(*)::int AS n FROM keyword
WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`;
return row?.n ?? 0;
}
}
@ -213,9 +222,28 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
nearby,
amenities: normalizeAmenities([...features, ...services]),
unverified: new Set(str(p['unverified'])),
signals: collectSignals(p),
};
}
/**
* .
* hashtags : ["#군산독채", "#군산감성숙소", ...]
* reviewSignals: [{ term: "바베큐", count: 47 }, ...]
* (·). .
*/
function collectSignals(p: Record<string, unknown>): string[] {
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
const reviews = raw
.filter((r): r is { term: string; count: number } =>
Boolean(r) && typeof (r as any).term === 'string')
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
.map((r) => r.term.trim())
.filter(Boolean);
return [...new Set([...reviews, ...tags])];
}
function publicMerchant(m: MerchantWithTaxonomy) {
return {
id: m.id, externalId: m.external_id, name: m.name,