매칭을 속성별 다중 질의 + 가중 RRF + 사실 기반 필터로 재구성

프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다.
실측: 통짜는 점수 폭 0.0076, 속성별로 쪼개면 0.0624 (8배).

- src/serving/match.rules.ts — 레인 빌더, 권역 판정, 수용 인원/시설 필터,
  시설 통제 어휘 정규화
- src/serving/match.service.ts — 레인별 검색 → 가중 RRF 융합 → 필터 →
  레인별 그룹(byLane) 출력. 근거(어느 레인 몇 위)를 함께 반환
- POST /v1/match 에 mode=fusion(기본) / single(기존 통짜, 비교용)
- 데모 페이지: 모드 토글, 레인 카드, 융합/레인별/배제됨 탭

레인 설계에서 실측으로 고친 것
- 브랜드 레인 제거 — 상호는 사전에 없어 generic '군산 펜션 ~예약'만 끌어왔다
- 권역/인근 레인 병합 — '신흥동' 토큰이 겹쳐 위치 키워드가 상위를 쓸어갔다
- RRF 상수 60 → 20 — 60은 1위/40위 기여도 차이가 1.6배뿐이라 generic이 유리했다

사실 기반 필터는 벡터가 못 거르는 모순을 배제한다.
스테이머뭄(최대 4인, 원도심) 기준 61건 배제.
미확인 시설은 배제하지 않고 '보류'로 표시한다 — 없음이 아니라 모름이므로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-09 16:00:00 +09:00
parent 1addd7a270
commit f7485aab32
7 changed files with 597 additions and 43 deletions

View File

@ -137,7 +137,7 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드** (데모 콘솔이 쓰는 API) |
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) |
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
@ -174,6 +174,60 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
}
```
### 매칭 — 속성별 다중 질의 + 사실 기반 필터
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측:
| 방식 | 점수 범위 | 폭 |
|---|---|---|
| 통짜 질의문 하나 | 0.8761 ~ 0.8837 | 0.0076 |
| 속성별로 쪼갠 질의 | 0.8552 ~ 0.9176 | **0.0624** |
976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다.
그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다.
| 레인 | 가중치 | 질의문 예시 |
|---|---|---|
| 유형 | 1.0 | `군산 펜션 독채 감성숙소` |
| 위치 | 0.7 | `원도심 신흥동 말랭이마을 동국사 근처` |
| 동반자 | 0.6 | `커플 친구 가족 혼자` |
| 시설 | 0.6 | `프라이빗` |
레인 설계에서 실측으로 배운 것 세 가지.
- **브랜드 레인을 두면 안 된다.** 상호는 사전에 없으므로 결국 `군산 펜션` 만 남아
가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 `~예약` 으로 도배됐다.
- **레인끼리 겹치면 안 된다.** 권역과 인근을 따로 두었더니 `신흥동` 토큰이 양쪽에 걸려
위치 키워드가 상위를 쓸어갔고, 정작 핵심인 `군산 펜션 독채` 가 8위로 밀렸다. 한 레인으로 합쳤다.
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
#### 사실 기반 필터 — 벡터가 못 거르는 것
임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.
| 규칙 | 예시 |
|---|---|
| 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 |
| 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 |
| 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 |
| **미확인 시설** | `바베큐` 가 `unverified` → 배제하지 않고 **보류** 표시 |
마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다.
스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다.
#### 레인별 출력 = SEO 페이지 배분
응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 —
한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다.
| 레인 | → 페이지 | 주력 |
|---|---|---|
| 유형 | 메인 | 군산 펜션 독채 |
| 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 |
| 동반자 | 객실 | 군산 커플 프라이빗 펜션 |
| 시설 | 시설 | 군산 프라이빗 펜션 |
### 매칭 예시
```bash

View File

@ -85,6 +85,31 @@
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
.tblwrap{overflow-x:auto}
.modes{display:flex;gap:0;border:1px solid var(--line);border-radius:5px;overflow:hidden}
.modes button{border:0;border-radius:0;background:var(--surface);color:var(--muted);font-size:13px;padding:12px 16px;font-weight:500}
.modes button[aria-pressed=true]{background:var(--accent);color:#fff}
.lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:16px}
.lane{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:11px 13px}
.lane b{font-size:12.5px}
.lane .w{font-family:var(--mono);font-size:10.5px;color:var(--accent);margin-left:5px}
.lane .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:5px;line-height:1.55;word-break:break-all}
.tabs{display:flex;gap:6px;margin-bottom:12px}
.tabs button{font-size:12.5px;padding:6px 13px;border-radius:20px;border:1px solid var(--line);
background:var(--surface);color:var(--muted);font-weight:400}
.tabs button[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
.prov{display:inline-flex;gap:4px;flex-wrap:wrap}
.prov span{font-family:var(--mono);font-size:10px;background:var(--surface2);color:var(--muted);
padding:1px 5px;border-radius:3px}
.hold{font-size:10.5px;font-family:var(--mono);color:var(--warn);background:var(--warn-bg);
padding:1px 6px;border-radius:3px;white-space:nowrap}
.facts{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
.facts span{font-family:var(--mono);font-size:11px;background:var(--surface2);color:var(--ink-soft);
padding:3px 8px;border-radius:3px}
.lanegroup{margin-bottom:20px}
.lanegroup h3{margin:0 0 4px;font-size:13.5px}
.lanegroup .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin:0 0 8px}
.exrow td{color:var(--muted)}
.exwhy{font-family:var(--mono);font-size:11px;color:var(--stop)}
</style>
</head>
<body>
@ -98,6 +123,10 @@
<form id="f">
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
<div class="modes">
<button type="button" id="m-fusion" aria-pressed="true">융합</button>
<button type="button" id="m-single" aria-pressed="false">통짜</button>
</div>
<select id="limit">
<option value="30">상위 30</option>
<option value="50" selected>상위 50</option>
@ -112,6 +141,8 @@
<div class="grid">
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
<section>
<div class="lanes" id="lanes"></div>
<div class="tabs" id="tabs"></div>
<div class="toolbar" id="filters"></div>
<div class="card" style="padding:0">
<div class="tblwrap"><table id="tbl">
@ -126,15 +157,23 @@
<script>
const API = location.origin;
const $ = (s) => document.querySelector(s);
let LAST = null, FILTER = null;
let LAST = null, FILTER = null, MODE = 'fusion', TAB = 'fused';
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
'아이랑 물놀이 하기 좋은 곳', '선유도 근처에서 바베큐 되는 독채'];
'아이랑 물놀이 하기 좋은 곳', '말랭이마을 걸어서 갈 수 있는 숙소'];
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
$('#ex').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
$('#q').value = b.dataset.q; run();
});
for (const m of ['fusion', 'single']) {
$('#m-' + m).addEventListener('click', () => {
MODE = m;
$('#m-fusion').setAttribute('aria-pressed', String(m === 'fusion'));
$('#m-single').setAttribute('aria-pressed', String(m === 'single'));
run();
});
}
async function health() {
try {
@ -151,13 +190,13 @@ async function run() {
try {
const res = await fetch(API + '/v1/match', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, limit: Number($('#limit').value) }),
body: JSON.stringify({ query, limit: Number($('#limit').value), mode: MODE }),
});
if (!res.ok) throw new Error(await res.text());
LAST = await res.json(); FILTER = null;
renderSide(); renderFilters(); renderTable();
LAST = await res.json(); FILTER = null; TAB = 'fused';
renderSide(); renderLanes(); renderTabs(); renderBody();
} catch (e) {
$('#err').innerHTML = `<div class="err">요청 실패 — ${String(e.message || e).slice(0, 300)}</div>`;
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
} finally { $('#go').disabled = false; }
}
@ -165,64 +204,123 @@ function renderSide() {
const m = LAST.resolved;
if (!m) {
$('#side').innerHTML = `<h2>업체 미해석</h2>
<p style="font-size:13px;color:var(--muted);margin:0">
일치하는 업체가 없어 입력 문장을 그대로 질의로 사용했습니다.</p>
<div class="qtext"><b>질의문</b><br>${esc(LAST.queryText)}</div>`;
<p style="font-size:13px;color:var(--muted);margin:0">일치하는 업체가 없어 입력 문장을 그대로 질의로 씁니다.</p>`;
return;
}
const p = m.profile || {};
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
const chips = (k, label) => arr(k).length
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
const f = LAST.facts;
$('#side').innerHTML = `
<h2>해석된 업체</h2>
<dl class="kv">
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
<dt>ID</dt><dd style="font-family:var(--mono);font-size:11.5px">${esc(m.externalId)}</dd>
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
${chips('services', '서비스')}${chips('features', '시설')}
${chips('audiences', '동반자')}${chips('nearby', '인근')}
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
</dl>
<div class="qtext"><b>임베딩에 사용한 질의문</b><br>${esc(LAST.queryText)}</div>`;
${f ? `<div class="qtext"><b>필터에 쓰는 사실</b>
<div class="facts">
<span>권역 ${esc(f.areaGroup || '미상')}</span>
<span>최대 ${f.capacityMax ?? '?'}인</span>
${f.amenities.map(a => `<span>${esc(a)}</span>`).join('')}
${f.unverified.map(u => `<span style="color:var(--warn)">${esc(u)}?</span>`).join('')}
</div></div>` : ''}
${LAST.mode === 'single' ? `<div class="qtext"><b>임베딩에 사용한 질의문 (통짜)</b><br>${esc(LAST.queryText)}</div>` : ''}`;
}
function renderLanes() {
if (LAST.mode !== 'fusion') { $('#lanes').innerHTML = ''; return; }
$('#lanes').innerHTML = LAST.lanes.map(l => `
<div class="lane">
<b>${esc(l.label)}</b><span class="w">w=${l.weight}</span>
<div class="q">${esc(l.text)}</div>
</div>`).join('');
}
function renderTabs() {
if (LAST.mode !== 'fusion') { $('#tabs').innerHTML = ''; return; }
const tabs = [['fused', `융합 순위 ${LAST.matches.length}`],
['lanes', '레인별 (페이지 배분)'],
['excluded', `배제됨 ${LAST.excludedTotal}`]];
$('#tabs').innerHTML = tabs.map(([k, label]) =>
`<button data-t="${k}" aria-pressed="${k === TAB}">${label}</button>`).join('');
$('#tabs').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
TAB = b.dataset.t;
[...$('#tabs').querySelectorAll('button')].forEach(x => x.setAttribute('aria-pressed', String(x.dataset.t === TAB)));
renderBody();
};
}
function renderBody() {
if (LAST.mode === 'fusion' && TAB === 'lanes') return renderLaneGroups();
if (LAST.mode === 'fusion' && TAB === 'excluded') return renderExcluded();
renderFilters(); renderTable(LAST.matches);
}
function renderFilters() {
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
$('#filters').innerHTML =
`<button class="filter" data-c="" aria-pressed="true">전체</button>` +
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="false">${esc(c)}</button>`).join('') +
`<span class="count">사전 ${LAST.total.toLocaleString()}건 중 상위 ${LAST.matches.length}건</span>`;
`<button class="filter" data-c="" aria-pressed="${!FILTER}">전체</button>` +
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="${FILTER === c}">${esc(c)}</button>`).join('') +
`<span class="count">사전 ${LAST.total.toLocaleString()}건 · ${LAST.mode === 'fusion' ? '융합' : '통짜'}</span>`;
$('#filters').onclick = (e) => {
const b = e.target.closest('.filter'); if (!b) return;
FILTER = b.dataset.c || null;
[...$('#filters').querySelectorAll('.filter')]
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
renderTable();
renderTable(LAST.matches);
};
}
function renderTable() {
const rows = LAST.matches.filter(m => !FILTER || m.category === FILTER);
const tb = $('#tbl tbody');
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>'; return; }
const max = Math.max(...rows.map(r => r.score)), min = Math.min(...rows.map(r => r.score));
const norm = (s) => max === min ? 100 : 6 + 94 * (s - min) / (max - min);
tb.innerHTML = rows.map((r, i) => `
<tr>
<td class="rank">${i + 1}</td>
<td class="kw">${esc(r.canonical)}
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
${r.aliases?.length ? `<small>흡수: ${r.aliases.map(esc).join(', ')}</small>` : ''}
</td>
<td><div style="display:flex;align-items:center;gap:8px">
<div class="bar"><i style="width:${norm(r.score).toFixed(1)}%"></i></div>
<span class="score">${r.score.toFixed(3)}</span></div></td>
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
<td class="cat">${esc(r.category || '-')}</td>
</tr>`).join('');
function row(r, i, metric) {
const val = metric === 'rrf' ? r.rrf : r.score;
return `<tr>
<td class="rank">${i + 1}</td>
<td class="kw">${esc(r.canonical)}
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
${r.status === 'hold' ? `<span class="hold">보류 · ${esc(r.holdReason || '')}</span>` : ''}
${r.lanes ? `<small class="prov">${r.lanes.slice(0, 4).map(l => `<span>${esc(l.label)}${l.rank}</span>`).join('')}</small>` : ''}
</td>
<td><span class="score">${metric === 'rrf' ? val.toFixed(5) : val.toFixed(4)}</span></td>
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
<td class="cat">${esc(r.category || '-')}</td>
</tr>`;
}
function renderTable(rows) {
const list = rows.filter(m => !FILTER || m.category === FILTER);
const metric = LAST.mode === 'fusion' ? 'rrf' : 'cos';
$('#tbl thead').innerHTML =
`<tr><th class="rank">#</th><th>키워드</th><th>${metric === 'rrf' ? 'RRF' : '유사도'}</th><th>의도</th><th>카테고리</th></tr>`;
$('#tbl tbody').innerHTML = list.length
? list.map((r, i) => row(r, i, metric)).join('')
: '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>';
}
function renderLaneGroups() {
$('#filters').innerHTML = '<span class="count">레인 1위가 그 페이지의 주력 키워드가 된다</span>';
$('#tbl').closest('.card').innerHTML = '<div style="padding:18px">' + LAST.byLane.map(l => `
<div class="lanegroup">
<h3>${esc(l.label)} <span class="chip">w=${l.weight}</span></h3>
<p class="q">${esc(l.text)}</p>
<table><tbody>${l.items.map((r, i) => row(r, i, 'cos')).join('')}</tbody></table>
</div>`).join('') + '</div>';
}
function renderExcluded() {
$('#filters').innerHTML = `<span class="count">사실 기반 필터로 걸러낸 ${LAST.excludedTotal}건 — 벡터만으로는 못 거른다</span>`;
$('#tbl thead').innerHTML = '<tr><th class="rank">#</th><th>키워드</th><th colspan="3">배제 사유</th></tr>';
$('#tbl tbody').innerHTML = LAST.excluded.length
? LAST.excluded.map((e, i) => `<tr class="exrow"><td class="rank">${i + 1}</td>
<td class="kw">${esc(e.canonical)}</td>
<td colspan="3" class="exwhy">${esc(e.reason)}</td></tr>`).join('')
: '<tr><td colspan="5"><div class="empty">배제된 항목 없음</div></td></tr>';
}
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>

164
src/serving/match.rules.ts Normal file
View File

@ -0,0 +1,164 @@
/**
* 매칭 규칙 테이블.
*
* 두 종류가 있다.
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다
* (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다)
*/
export interface MerchantFacts {
name: string;
region: string | null;
industry: string | null;
description: string;
address: string | null;
areaGroup: AreaGroup | null;
capacityMax: number | null;
services: string[];
features: string[];
audiences: string[];
nearby: string[];
amenities: Set<string>; // 정규화된 보유 시설
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
}
export type AreaGroup = '해안·도서' | '원도심' | '시내';
export interface Lane {
key: string;
label: string;
weight: number;
text: string;
}
// ──────────────────────────────────────────── 권역 판정
const AREA_TERMS: Record<AreaGroup, string[]> = {
'해안·도서': ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도', '새만금',
'비응항', '비응도', '오식도', '해수욕장', '몽돌', '오션뷰', '바다뷰', '해변', '섬'],
'원도심': ['원도심', '신흥동', '영화동', '월명', '말랭이마을', '동국사', '초원사진관', '이성당',
'경암동', '근대역사', '근대문화', '시간여행', '해망굴', '째보선창', '뜬다리', '일본식가옥'],
'시내': ['나운동', '수송동', '미룡동', '조촌동', '은파호수공원', '군산역', '시외버스'],
};
export function detectAreaGroup(text: string): AreaGroup | null {
const hits = (Object.entries(AREA_TERMS) as [AreaGroup, string[]][])
.map(([g, terms]) => [g, terms.filter((t) => text.includes(t)).length] as const)
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1]);
return hits[0]?.[0] ?? null;
}
/** 키워드가 어느 권역에 속하는지 (해당 없으면 null = 권역 중립) */
export function keywordAreaGroup(keyword: string): AreaGroup | null {
return detectAreaGroup(keyword);
}
// ──────────────────────────────────────────── 수용 인원 모순
const GROUP_TERMS = ['단체', '워크샵', '워크숍', 'mt', '엠티', '20인', '15인', '10인',
'세미나', '단합', '대형', '펜션동', '전체대관'];
const GROUP_MIN_CAPACITY = 8;
export function violatesCapacity(keyword: string, capacityMax: number | null): boolean {
if (capacityMax === null || capacityMax >= GROUP_MIN_CAPACITY) return false;
const k = keyword.toLowerCase();
return GROUP_TERMS.some((t) => k.includes(t));
}
// ──────────────────────────────────────────── 시설 요구 조건
/** 키워드에 이 말이 있으면 해당 시설을 실제로 보유해야 한다 */
const AMENITY_REQUIRED: Array<[RegExp, string]> = [
[/바베큐|바비큐|바베큐장|그릴/, '바베큐'],
[/수영장|풀빌라|인피니티풀|온수풀|야외수영/, '수영장'],
[/스파|자쿠지|월풀|반신욕/, '스파'],
[/애견|반려|펫/, '애견동반'],
[/주차/, '주차'],
[/노래방/, '노래방'],
[/조식/, '조식'],
[/키즈룸|트램폴린/, '키즈시설'],
[/불멍|화로대|캠프파이어/, '불멍'],
[/빔프로젝터|넷플릭스/, '미디어'],
[/세미나실/, '세미나실'],
];
/** 시설 표기 흔들림을 통제 어휘로 모은다 */
const AMENITY_SYNONYMS: Array<[RegExp, string]> = [
[/바베큐|바비큐|그릴/, '바베큐'],
[/수영장|풀|풀빌라/, '수영장'],
[/스파|자쿠지|월풀|욕조/, '스파'],
[/애견|반려|펫/, '애견동반'],
[/주차/, '주차'],
[/노래방/, '노래방'],
[/조식|아침/, '조식'],
[/키즈|트램폴린|유아/, '키즈시설'],
[/불멍|화로|캠프파이어/, '불멍'],
[/넷플릭스|빔프로젝터|ott/i, '미디어'],
[/세미나/, '세미나실'],
];
export function normalizeAmenities(raw: string[]): Set<string> {
const out = new Set<string>();
for (const r of raw) {
for (const [re, canon] of AMENITY_SYNONYMS) if (re.test(r)) out.add(canon);
}
return out;
}
export type AmenityVerdict = { ok: true } | { ok: false; hold: boolean; amenity: string };
export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerdict {
for (const [re, amenity] of AMENITY_REQUIRED) {
if (!re.test(keyword)) continue;
if (facts.amenities.has(amenity)) return { ok: true };
// 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름" — 배제하지 않고 보류
const unverified = [...facts.unverified].some((u) =>
AMENITY_SYNONYMS.some(([sre, canon]) => canon === amenity && sre.test(u)));
return { ok: false, hold: unverified, amenity };
}
return { ok: true };
}
// ──────────────────────────────────────────── 서브 질의 빌더
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
/**
* 레인 설계 원칙
* 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고,
* 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다.
* 지역+업종 앵커는 유형 레인에만 둔다.
* 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아
* 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다).
* 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다.
*/
export function buildLanes(f: MerchantFacts): Lane[] {
const lanes: Lane[] = [];
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();
if (text) lanes.push({ key, label, weight, text });
};
const isType = (x: string) => STAY_TYPE_HINTS.some((h) => x.includes(h));
const typeWords = [...f.features, ...f.services].filter(isType).slice(0, 3);
// 시설: 유형어·수용인원·권역어를 걷어낸 나머지 + 정규화된 보유 시설
const amenityWords = [
...f.amenities,
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
].slice(0, 6);
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
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);
return lanes;
}
function districtOf(address: string | null): string | null {
if (!address) return null;
const m = address.match(/([가-힣]+(?:동|읍|면|리))/);
return m?.[1] ?? null;
}

View File

@ -0,0 +1,225 @@
import { Inject, Injectable } from '@nestjs/common';
import { Sql, toVector } from '../db/db';
import { PG } from '../db/db.module';
import { EmbeddingProvider } from '../embedding/types';
import { normalizeKeyword } from '../keywords/normalize';
import { MerchantWithTaxonomy } from '../merchants/merchants.service';
import {
AreaGroup, Lane, MerchantFacts, buildLanes, checkAmenity, detectAreaGroup,
keywordAreaGroup, normalizeAmenities, violatesCapacity,
} from './match.rules';
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
const RRF_K = 20;
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
interface Hit {
id: string; canonical: string; intent: string; kind: string;
category: string | null; aliases: string[]; score: number;
}
export interface MatchRow extends Hit {
rrf: number;
status: 'ok' | 'hold';
holdReason?: string;
lanes: Array<{ key: string; label: string; rank: number; score: number }>;
linked: boolean;
}
@Injectable()
export class MatchService {
constructor(
@Inject(PG) private readonly sql: Sql,
private readonly embedder: EmbeddingProvider,
) {}
/** 속성별 서브 질의 → 가중 RRF 융합 → 사실 기반 필터 */
async fusion(rawQuery: string, limit: number) {
const query = rawQuery.trim();
const merchant = await this.resolveMerchant(query);
const facts = merchant ? toFacts(merchant) : null;
const lanes: Lane[] = facts
? buildLanes(facts)
: [{ key: 'free', label: '입력문', weight: 1.0, text: query }];
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)),
);
// 가중 RRF 융합
const acc = new Map<string, { hit: Hit; rrf: number; lanes: MatchRow['lanes'] }>();
perLane.forEach((hits, li) => {
const lane = lanes[li];
hits.forEach((hit, idx) => {
if (hit.score < LANE_FLOOR) return;
const rank = idx + 1;
const contrib = lane.weight / (RRF_K + rank);
const cur = acc.get(hit.id) ?? { hit, rrf: 0, lanes: [] };
cur.rrf += contrib;
cur.lanes.push({ key: lane.key, label: lane.label, rank, score: hit.score });
if (hit.score > cur.hit.score) cur.hit = hit;
acc.set(hit.id, cur);
});
});
// 사실 기반 필터
const kept: MatchRow[] = [];
const excluded: Array<{ canonical: string; reason: string }> = [];
for (const { hit, rrf, lanes: ls } of acc.values()) {
if (facts) {
if (violatesCapacity(hit.canonical, facts.capacityMax)) {
excluded.push({ canonical: hit.canonical, reason: `최대 ${facts.capacityMax}인 — 단체 키워드` });
continue;
}
const kwArea = keywordAreaGroup(hit.canonical);
if (kwArea && facts.areaGroup && kwArea !== facts.areaGroup) {
excluded.push({ canonical: hit.canonical, reason: `권역 불일치 — ${kwArea} (업체는 ${facts.areaGroup})` });
continue;
}
const am = checkAmenity(hit.canonical, facts);
if (!am.ok && !am.hold) {
excluded.push({ canonical: hit.canonical, reason: `미보유 시설 — ${am.amenity}` });
continue;
}
kept.push({
...hit, rrf,
lanes: ls.sort((a, b) => a.rank - b.rank),
status: am.ok ? 'ok' : 'hold',
holdReason: am.ok ? undefined : `${am.amenity} 미확인 — 사업자 확인 필요`,
linked: false,
});
} else {
kept.push({ ...hit, rrf, lanes: ls.sort((a, b) => a.rank - b.rank), status: 'ok', linked: false });
}
}
kept.sort((a, b) => b.rrf - a.rrf);
const top = kept.slice(0, limit);
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
const keptById = new Map(kept.map((k) => [k.id, k]));
const byLane = lanes.map((lane, li) => ({
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
items: perLane[li]
.map((h) => keptById.get(h.id))
.filter((x): x is MatchRow => Boolean(x))
.slice(0, 8),
}));
await this.markLinked([...top, ...byLane.flatMap((l) => l.items)], merchant?.id ?? null);
return {
mode: 'fusion' as const,
input: query,
resolved: merchant ? publicMerchant(merchant) : null,
facts: facts && {
areaGroup: facts.areaGroup,
capacityMax: facts.capacityMax,
amenities: [...facts.amenities],
unverified: [...facts.unverified],
},
lanes: lanes.map((l, i) => ({
...l, top: perLane[i][0]?.canonical ?? null, topScore: perLane[i][0]?.score ?? null,
})),
embeddingProvider: this.embedder.name,
total: await this.dictionarySize(),
matches: top,
byLane,
excluded: excluded.slice(0, 40),
excludedTotal: excluded.length,
};
}
private async laneSearch(embedding: number[], limit: number): 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
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}`;
return rows.map((r) => ({ ...r, score: Number(r.score) }));
}
private async markLinked(rows: MatchRow[], merchantId: string | null) {
if (!merchantId || rows.length === 0) return;
const ids = rows.map((r) => r.id);
const linked = await this.sql<Array<{ keyword_id: string }>>`
SELECT keyword_id FROM merchant_keyword
WHERE merchant_id = ${merchantId} AND keyword_id = ANY(${ids}::uuid[])`;
const set = new Set(linked.map((l) => l.keyword_id));
for (const r of rows) r.linked = set.has(r.id);
}
async resolveMerchant(query: string) {
const norm = normalizeKeyword(query);
if (!norm) return null;
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
SELECT m.*,
i.name AS industry_name, i.path::text AS industry_path,
r.name AS region_name, r.path::text AS region_path,
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
FROM merchant m
LEFT JOIN industry i ON i.id = m.industry_id
LEFT JOIN region r ON r.id = m.region_id
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
OR m.external_id = ${query}
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
ORDER BY sim DESC NULLS LAST
LIMIT 1`;
return rows[0] ?? null;
}
async dictionarySize() {
const [row] = await this.sql<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`;
return row?.n ?? 0;
}
}
function str(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
}
function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
const p = (m.profile ?? {}) as Record<string, unknown>;
const features = str(p['features']);
const services = str(p['services']);
const nearby = str(p['nearby']);
const address = typeof p['address'] === 'string' ? p['address'] : null;
const cap = p['capacity'] as { max?: number } | undefined;
const areaSource = [address ?? '', ...nearby, m.description].join(' ');
const areaGroup: AreaGroup | null = detectAreaGroup(areaSource);
return {
name: m.name,
region: m.region_name,
industry: m.industry_name,
description: m.description,
address,
areaGroup,
capacityMax: typeof cap?.max === 'number' ? cap.max : null,
services, features,
audiences: str(p['audiences']),
nearby,
amenities: normalizeAmenities([...features, ...services]),
unverified: new Set(str(p['unverified'])),
};
}
function publicMerchant(m: MerchantWithTaxonomy) {
return {
id: m.id, externalId: m.external_id, name: m.name,
region: m.region_name, industry: m.industry_name,
description: m.description, siteUrl: m.site_url, profile: m.profile ?? {},
};
}

View File

@ -1,9 +1,13 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { MatchService } from './match.service';
import { ServingService } from './serving.service';
@Controller('v1')
export class ServingController {
constructor(private readonly serving: ServingService) {}
constructor(
private readonly serving: ServingService,
private readonly matcher: MatchService,
) {}
/** 발행된 사이트가 렌더링 시 호출 — SEO 메타 */
@Get('sites/:id/seo')
@ -23,10 +27,17 @@ export class ServingController {
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
}
/** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드 */
/**
* 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드.
* mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터
* mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용)
*/
@Post('match')
match(@Body() body: { query: string; limit?: number }) {
return this.serving.match(body.query ?? '', Math.min(body.limit ?? 40, 200));
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
const limit = Math.min(body.limit ?? 40, 200);
return body.mode === 'single'
? this.serving.match(body.query ?? '', limit)
: this.matcher.fusion(body.query ?? '', limit);
}
/** 성과 피드백 주입 (Search Console / 유입 로그) */

View File

@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { KeywordsModule } from '../keywords/keywords.module';
import { MerchantsModule } from '../merchants/merchants.module';
import { DemoController } from './demo.controller';
import { MatchService } from './match.service';
import { ServingController } from './serving.controller';
import { ServingService } from './serving.service';
@Module({
imports: [MerchantsModule, KeywordsModule],
controllers: [ServingController, DemoController],
providers: [ServingService],
providers: [ServingService, MatchService],
})
export class ServingModule {}

View File

@ -134,6 +134,7 @@ export class ServingService {
LIMIT ${limit}`;
return {
mode: 'single' as const,
input: query,
resolved: merchant
? {