스테이머뭄 매칭 품질 개선 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건 적재**. 적재 결과: 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

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", "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",

View File

@ -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
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} `); 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

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[]; 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;
} }

View File

@ -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,