o2o-site-ontology/public/demo.html
hbyang f7485aab32 매칭을 속성별 다중 질의 + 가중 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>
2026-09-09 16:00:00 +09:00

334 lines
17 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
<style>
:root{
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
.sub{margin:0;color:var(--muted);font-size:14px}
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
background:var(--surface);color:var(--ink)}
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:progress}
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
.examples button:hover{color:var(--accent);border-color:var(--accent)}
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--ink-soft)}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
padding:2px 7px;border-radius:3px}
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:hover td{background:var(--surface2)}
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
font-variant-numeric:tabular-nums}
.kw{font-weight:500}
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
font-variant-numeric:tabular-nums;text-align:right}
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
.t-local{background:var(--accent-bg);color:var(--accent)}
.t-transactional{background:var(--warn-bg);color:var(--warn)}
.t-informational{background:var(--info-bg);color:var(--info)}
.t-brand{background:var(--surface2);color:var(--ink-soft)}
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
.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>
<div class="wrap">
<header>
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
<h1>키워드 매칭 콘솔</h1>
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
<div class="status" id="status"><span>연결 확인 중…</span></div>
</header>
<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>
<option value="100">상위 100</option>
</select>
<button type="submit" id="go">매칭</button>
</form>
<div class="examples" id="ex"></div>
<div id="err"></div>
<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">
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
</table></div>
</div>
</section>
</div>
</div>
<script>
const API = location.origin;
const $ = (s) => document.querySelector(s);
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 {
const h = await (await fetch(API + '/health')).json();
$('#status').innerHTML =
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
}
async function run() {
const query = $('#q').value.trim();
if (!query) return;
$('#go').disabled = true; $('#err').innerHTML = '';
try {
const res = await fetch(API + '/v1/match', {
method: 'POST', headers: { 'content-type': 'application/json' },
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; TAB = 'fused';
renderSide(); renderLanes(); renderTabs(); renderBody();
} catch (e) {
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
} finally { $('#go').disabled = false; }
}
function renderSide() {
const m = LAST.resolved;
if (!m) {
$('#side').innerHTML = `<h2>업체 미해석</h2>
<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>소개</dt><dd>${esc(m.description || '-')}</dd>
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
</dl>
${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="${!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(LAST.matches);
};
}
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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
health(); run();
</script>
</body>
</html>