스테이머뭄 매칭 품질 개선 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:
parent
f7485aab32
commit
25f2cc871d
43
README.md
43
README.md
@ -86,7 +86,20 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
|||||||
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
|
적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**.
|
||||||
|
|
||||||
`npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다.
|
`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배뿐이라
|
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
||||||
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
|
깊은 순위의 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인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.
|
임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
8
data/related-keywords.sample.csv
Normal file
8
data/related-keywords.sample.csv
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다.
|
||||||
|
# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것)
|
||||||
|
relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx
|
||||||
|
군산독채펜션,210,1830,중간
|
||||||
|
군산감성숙소,90,760,낮음
|
||||||
|
군산2인펜션,40,310,낮음
|
||||||
|
군산뚜벅이여행,30,240,낮음
|
||||||
|
말랭이마을숙소,10,90,낮음
|
||||||
|
@ -15,7 +15,9 @@
|
|||||||
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
|
"db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed",
|
||||||
"smoke": "tsx scripts/smoke.ts",
|
"smoke": "tsx scripts/smoke.ts",
|
||||||
"dataset:build": "node scripts/build-dataset.mjs",
|
"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": {
|
"dependencies": {
|
||||||
"@huggingface/transformers": "^4.2.0",
|
"@huggingface/transformers": "^4.2.0",
|
||||||
|
|||||||
@ -37,7 +37,15 @@ const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약'
|
|||||||
// 동반자
|
// 동반자
|
||||||
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
|
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
|
||||||
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
|
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 = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
|
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 });
|
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 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 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 — 섬·권역 × 숙소유형
|
// T2 — 섬·권역 × 숙소유형
|
||||||
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
|
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 });
|
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 — 관광지 앵커
|
// T6 — 관광지 앵커
|
||||||
for (const sp of SPOTS) {
|
for (const sp of SPOTS) {
|
||||||
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
|
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
|
||||||
@ -129,7 +153,8 @@ for (const sp of SPOTS) {
|
|||||||
// T7 — 태그 (칩 UI 용 짧은 패싯)
|
// T7 — 태그 (칩 UI 용 짧은 패싯)
|
||||||
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
|
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 });
|
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
|
||||||
|
|
||||||
// T8 — 질문형 (AEO)
|
// T8 — 질문형 (AEO)
|
||||||
|
|||||||
90
scripts/import-related.ts
Normal file
90
scripts/import-related.ts
Normal 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();
|
||||||
@ -72,7 +72,22 @@ async function main() {
|
|||||||
});
|
});
|
||||||
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
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 }>>`
|
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
|
SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim
|
||||||
FROM keyword k1 JOIN keyword k2
|
FROM keyword k1 JOIN keyword k2
|
||||||
|
|||||||
35
scripts/purge-nondataset.ts
Normal file
35
scripts/purge-nondataset.ts
Normal 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); });
|
||||||
@ -21,6 +21,8 @@ export interface MerchantFacts {
|
|||||||
nearby: string[];
|
nearby: string[];
|
||||||
amenities: Set<string>; // 정규화된 보유 시설
|
amenities: Set<string>; // 정규화된 보유 시설
|
||||||
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
||||||
|
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
|
||||||
|
signals: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AreaGroup = '해안·도서' | '원도심' | '시내';
|
export type AreaGroup = '해안·도서' | '원도심' | '시내';
|
||||||
@ -133,8 +135,22 @@ const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|
|
|||||||
*/
|
*/
|
||||||
export function buildLanes(f: MerchantFacts): Lane[] {
|
export function buildLanes(f: MerchantFacts): Lane[] {
|
||||||
const lanes: Lane[] = [];
|
const lanes: Lane[] = [];
|
||||||
|
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
|
||||||
|
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
|
||||||
|
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
|
||||||
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
|
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 });
|
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('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
|
||||||
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
||||||
push('amenity', '시설', 0.6, amenityWords);
|
push('amenity', '시설', 0.6, amenityWords);
|
||||||
|
// 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다
|
||||||
|
push('signal', '고객언어', 0.9, f.signals.slice(0, 10));
|
||||||
|
|
||||||
return lanes;
|
return lanes;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
const RRF_K = 20;
|
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 생성물은 사전에 섞이면 안 된다.
|
||||||
|
const MATCH_SOURCES = ['dataset', 'manual'];
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
id: string; canonical: string; intent: string; kind: string;
|
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 vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
||||||
|
|
||||||
// 레인별 검색
|
// 레인별 검색.
|
||||||
|
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
|
||||||
|
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
|
||||||
const perLane = await Promise.all(
|
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 융합
|
// 가중 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 vec = toVector(embedding);
|
||||||
const rows = await this.sql<Hit[]>`
|
const rows = await this.sql<Hit[]>`
|
||||||
SELECT id, canonical, intent, kind, category, aliases,
|
SELECT id, canonical, intent, kind, category, aliases,
|
||||||
1 - (embedding <=> ${vec}::vector) AS score
|
1 - (embedding <=> ${vec}::vector) AS score
|
||||||
FROM keyword
|
FROM keyword
|
||||||
WHERE embedding IS NOT NULL
|
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
|
ORDER BY embedding <=> ${vec}::vector
|
||||||
LIMIT ${limit}`;
|
LIMIT ${limit}`;
|
||||||
return rows.map((r) => ({ ...r, score: Number(r.score) }));
|
return rows.map((r) => ({ ...r, score: Number(r.score) }));
|
||||||
@ -180,7 +188,8 @@ export class MatchService {
|
|||||||
|
|
||||||
async dictionarySize() {
|
async dictionarySize() {
|
||||||
const [row] = await this.sql<Array<{ n: number }>>`
|
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;
|
return row?.n ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,9 +222,28 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
|
|||||||
nearby,
|
nearby,
|
||||||
amenities: normalizeAmenities([...features, ...services]),
|
amenities: normalizeAmenities([...features, ...services]),
|
||||||
unverified: new Set(str(p['unverified'])),
|
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) {
|
function publicMerchant(m: MerchantWithTaxonomy) {
|
||||||
return {
|
return {
|
||||||
id: m.id, externalId: m.external_id, name: m.name,
|
id: m.id, externalId: m.external_id, name: m.name,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user