o2o-site-ontology/docs/architecture.html
hbyang 209a381091 SEO/AEO 키워드 온톨로지 서비스 초기 구현
발행된 사이트에 업체별 SEO/AEO 키워드를 제공하는 서비스.

- PostgreSQL 16 + pgvector/ltree/pg_trgm 단일 스토어
  (정확·의미·계층 조회를 한 엔진에서 처리)
- 키워드는 전역 사전 + merchant_keyword 연결 테이블 구조
- 4단계 계단식 중복제거: 금칙어 → normalized 완전일치 →
  pg_trgm → 코사인 유사도, 걸린 표기는 aliases[] 로 흡수
- BullMQ 생성 큐 (발행 즉시 / 일 1회 크론 / 성과 기반)
- OpenAI Structured Outputs + mock provider
  (API 키 없이 로컬 전 구간 동작)
- 서빙 API: /v1/sites/:id/seo, /aeo, /performance, /keywords/search
- docs/architecture.html 설계 도식

JSON-LD 조립과 o2o-site-AEO 연동은 후속 작업.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:56:34 +09:00

715 lines
37 KiB
HTML

<title>키워드 온톨로지 설계</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Gowun+Batang:wght@400;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&display=swap">
<style>
:root {
--bg: #f4f6f5;
--surface: #ffffff;
--surface-2: #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;
--stop-bg: #f6e0dc;
--shadow: 0 1px 2px rgba(16,24,25,.05), 0 8px 24px -16px rgba(16,24,25,.35);
--display: 'Gowun Batang', 'Apple SD Gothic Neo', serif;
--body: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', -apple-system, sans-serif;
--mono: 'IBM Plex Mono', 'SFMono-Regular', ui-monospace, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1213;
--surface: #141b1c;
--surface-2: #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;
--stop-bg: #37201d;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
}
:root[data-theme="dark"] {
--bg: #0d1213;
--surface: #141b1c;
--surface-2: #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;
--stop-bg: #37201d;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--body);
font-weight: 400;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1240px; margin: 0 auto; padding: 56px 32px 96px; }
.col { max-width: 760px; }
/* ---------- masthead ---------- */
.masthead { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 44px; }
.eyebrow {
font-family: var(--mono); font-size: 11px; font-weight: 500;
letter-spacing: .14em; text-transform: uppercase; color: var(--accent);
margin: 0 0 14px;
}
h1 {
font-family: var(--display); font-weight: 700;
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.18; letter-spacing: -.01em;
margin: 0 0 16px; text-wrap: balance;
}
.standfirst { font-size: 17px; color: var(--ink-soft); margin: 0; max-width: 62ch; font-weight: 300; }
.meta {
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px;
font-family: var(--mono); font-size: 11.5px; color: var(--muted);
}
.meta span {
border: 1px solid var(--line); border-radius: 3px;
padding: 3px 9px; background: var(--surface);
}
/* ---------- sections ---------- */
section { margin-top: 64px; }
h2 {
font-family: var(--display); font-weight: 700;
font-size: 25px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -.005em;
}
.lede { color: var(--muted); margin: 0 0 26px; max-width: 66ch; font-size: 15px; }
h3 {
font-size: 15px; font-weight: 600; margin: 34px 0 10px;
letter-spacing: .01em;
}
p { margin: 0 0 14px; max-width: 68ch; }
strong { font-weight: 600; }
code {
font-family: var(--mono); font-size: .875em;
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
color: var(--ink-soft);
}
/* ---------- figures ---------- */
figure { margin: 0 0 8px; }
.fig {
background: var(--surface); border: 1px solid var(--line);
border-radius: 6px; box-shadow: var(--shadow);
padding: 26px 22px 18px; margin: 8px 0 0;
}
.fig-scroll { overflow-x: auto; }
.fig svg { display: block; min-width: 720px; max-width: 100%; height: auto; color: var(--ink); }
figcaption {
font-size: 13px; color: var(--muted); margin-top: 16px;
padding-top: 14px; border-top: 1px solid var(--line-soft); max-width: 78ch;
}
/* ---------- tables ---------- */
.tbl-wrap { overflow-x: auto; margin: 20px 0 8px; }
table { border-collapse: collapse; width: 100%; font-size: 14px; min-width: 520px; }
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
thead th {
font-family: var(--mono); font-size: 11px; font-weight: 600;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid var(--line);
}
tbody tr:last-child td { border-bottom: none; }
td.mono, th.mono { font-family: var(--mono); font-size: 12.5px; }
.num { font-variant-numeric: tabular-nums; }
/* ---------- callout ---------- */
.verdict {
background: var(--accent-bg); border-left: 3px solid var(--accent);
padding: 18px 22px; border-radius: 0 5px 5px 0; margin: 24px 0;
}
.verdict p { margin: 0; max-width: none; }
.verdict p + p { margin-top: 10px; }
/* ---------- stage list (진짜 순서가 있는 것에만) ---------- */
ol.stages { list-style: none; counter-reset: s -1; padding: 0; margin: 20px 0 8px; }
ol.stages li {
counter-increment: s; display: grid;
grid-template-columns: 34px 1fr; gap: 16px;
padding: 14px 0; border-bottom: 1px solid var(--line-soft);
}
ol.stages li:last-child { border-bottom: none; }
ol.stages li::before {
content: counter(s);
font-family: var(--mono); font-size: 12px; font-weight: 600;
color: var(--accent); border: 1px solid var(--line);
border-radius: 3px; height: 26px; display: grid; place-items: center;
background: var(--surface);
}
ol.stages b { display: block; font-weight: 600; font-size: 14.5px; }
ol.stages span { font-size: 13.5px; color: var(--muted); }
ul.plain { padding-left: 20px; margin: 12px 0; }
ul.plain li { margin-bottom: 7px; max-width: 68ch; }
pre {
background: var(--surface); border: 1px solid var(--line); border-radius: 5px;
padding: 16px 18px; overflow-x: auto; font-family: var(--mono);
font-size: 12.5px; line-height: 1.75; margin: 16px 0; color: var(--ink-soft);
}
pre b { color: var(--accent); font-weight: 500; }
.pill {
display: inline-block; font-family: var(--mono); font-size: 11px;
padding: 2px 7px; border-radius: 3px; letter-spacing: .02em;
}
.pill-go { background: var(--accent-bg); color: var(--accent); }
.pill-warn { background: var(--warn-bg); color: var(--warn); }
.pill-stop { background: var(--stop-bg); color: var(--stop); }
footer {
margin-top: 76px; padding-top: 22px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--muted);
}
a { color: var(--accent); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
<div class="wrap">
<header class="masthead col">
<p class="eyebrow">o2o-site-ontology</p>
<h1>발행 사이트에 붙는<br>SEO/AEO 키워드 온톨로지</h1>
<p class="standfirst">
업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.
LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고,
발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.
</p>
<div class="meta">
<span>PostgreSQL 16 + pgvector</span>
<span>NestJS</span>
<span>BullMQ</span>
<span>OpenAI Structured Outputs</span>
</div>
</header>
<!-- ======================================================= 1 -->
<section>
<div class="col">
<h2>일반 DB 냐 벡터 DB 냐</h2>
<p class="lede">둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.</p>
</div>
<div class="tbl-wrap col">
<table>
<thead>
<tr><th>조회 유형</th><th>실제 질의</th><th>필요한 것</th></tr>
</thead>
<tbody>
<tr>
<td>정확 조회</td>
<td>업체 A 의 활성 키워드 20개</td>
<td class="mono">B-tree / 관계형 조인</td>
</tr>
<tr>
<td>의미 조회</td>
<td>이 후보가 기존 키워드와 의미상 겹치는가</td>
<td class="mono">vector (HNSW)</td>
</tr>
<tr>
<td>관계 탐색</td>
<td>업종 트리 상위에서 물려받을 공통 키워드</td>
<td class="mono">ltree 계층 / recursive CTE</td>
</tr>
</tbody>
</table>
</div>
<div class="verdict col">
<p><strong>결론 — PostgreSQL 하나로 시작한다.</strong>
<code>pgvector</code> + <code>ltree</code> + <code>pg_trgm</code> + <code>JSONB</code> 로 세 가지가 모두 한 엔진 안에서 해결되고,
무엇보다 <em>키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.</em></p>
<p>전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다.
벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.
Neo4j 도 같은 논리 — 고정 깊이 상속이면 <code>ltree</code> 로 충분하다.</p>
</div>
</section>
<!-- ======================================================= 2 -->
<section>
<div class="col">
<h2>전체 흐름</h2>
<p class="lede">생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1160 500" role="img"
aria-label="트리거가 BullMQ 큐에 적재되고, 생성 워커가 OpenAI 를 호출해 후보 키워드를 만들고, 4단계 중복제거를 거쳐 PostgreSQL 에 저장되며, 서빙 API 가 발행 사이트에 SEO/AEO payload 를 내려주고, 유입 성과가 다시 트리거로 돌아오는 순환 구조">
<defs>
<marker id="a1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="a1acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
</marker>
</defs>
<!-- boxes -->
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)" opacity="1">
<rect x="24" y="64" width="180" height="88" rx="4"/>
<rect x="252" y="64" width="180" height="88" rx="4"/>
<rect x="480" y="64" width="180" height="88" rx="4"/>
<rect x="708" y="248" width="180" height="88" rx="4"/>
<rect x="252" y="248" width="180" height="88" rx="4"/>
<rect x="252" y="400" width="400" height="60" rx="4"/>
</g>
<rect x="708" y="64" width="180" height="88" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.5"/>
<rect x="936" y="48" width="200" height="120" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<!-- labels -->
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
<text x="40" y="88" font-size="13" font-weight="600">트리거</text>
<text x="40" y="110" font-size="11" opacity=".75">사이트 발행 — 즉시</text>
<text x="40" y="127" font-size="11" opacity=".75">크론 03:00 — 30일 경과</text>
<text x="40" y="144" font-size="11" opacity=".75">성과 저조 — 재생성</text>
<text x="268" y="88" font-size="13" font-weight="600">BullMQ 큐</text>
<text x="268" y="110" font-size="11" opacity=".75">60초 dedupe 창</text>
<text x="268" y="127" font-size="11" opacity=".75">재시도 3회 · 지수 백오프</text>
<text x="268" y="144" font-size="11" opacity=".75">동시성 2</text>
<text x="496" y="88" font-size="13" font-weight="600">생성 워커</text>
<text x="496" y="110" font-size="11" opacity=".75">OpenAI · gpt-4.1-mini</text>
<text x="496" y="127" font-size="11" opacity=".75">Structured Outputs</text>
<text x="496" y="144" font-size="11" opacity=".75">임베딩 배치 1회</text>
<text x="724" y="88" font-size="13" font-weight="600" fill="var(--warn)">중복제거 4단계</text>
<text x="724" y="110" font-size="11" fill="var(--warn)" opacity=".9">해시 → trigram → 벡터</text>
<text x="724" y="127" font-size="11" fill="var(--warn)" opacity=".9">미일치만 신규 등록</text>
<text x="724" y="144" font-size="11" fill="var(--warn)" opacity=".9">나머지는 alias 흡수</text>
<text x="952" y="76" font-size="13" font-weight="600" fill="var(--accent)">PostgreSQL 16</text>
<text x="952" y="98" font-size="11" fill="var(--accent)" opacity=".9">pgvector · ltree · pg_trgm</text>
<text x="952" y="120" font-size="11" fill="var(--accent)" opacity=".9">keyword (전역 사전)</text>
<text x="952" y="137" font-size="11" fill="var(--accent)" opacity=".9">merchant_keyword</text>
<text x="952" y="154" font-size="11" fill="var(--accent)" opacity=".9">qa_pair · generation_run</text>
<text x="724" y="272" font-size="13" font-weight="600">Serving API</text>
<text x="724" y="294" font-size="11" opacity=".75">GET /v1/sites/:id/seo</text>
<text x="724" y="311" font-size="11" opacity=".75">GET /v1/sites/:id/aeo</text>
<text x="724" y="328" font-size="11" opacity=".75">읽기 99% · 캐시 대상</text>
<text x="268" y="272" font-size="13" font-weight="600">발행된 사이트</text>
<text x="268" y="294" font-size="11" opacity=".75">o2o-site-AEO</text>
<text x="268" y="311" font-size="11" opacity=".75">렌더링 시 호출</text>
<text x="268" y="426" font-size="13" font-weight="600">성과 수집</text>
<text x="268" y="447" font-size="11" opacity=".75">Search Console · 네이버 서치어드바이저 · 유입 로그</text>
</g>
<!-- flow arrows -->
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a1)">
<line x1="204" y1="108" x2="244" y2="108"/>
<line x1="432" y1="108" x2="472" y2="108"/>
<line x1="660" y1="108" x2="700" y2="108"/>
<line x1="888" y1="108" x2="928" y2="108"/>
<path d="M1036 168 L1036 292 L896 292"/>
<line x1="708" y1="292" x2="440" y2="292"/>
<line x1="342" y1="336" x2="342" y2="392"/>
<path d="M252 430 L114 430 L114 160"/>
</g>
<!-- prompt feedback (dashed, accent) -->
<g stroke="var(--accent)" stroke-width="1.4" fill="none" stroke-dasharray="5 4" marker-end="url(#a1acc)">
<path d="M1036 48 L1036 24 L570 24 L570 56"/>
</g>
<!-- arrow labels -->
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".7">
<text x="224" y="100" text-anchor="middle">적재</text>
<text x="452" y="100" text-anchor="middle">job</text>
<text x="680" y="100" text-anchor="middle">후보</text>
<text x="908" y="100" text-anchor="middle">write</text>
<text x="1046" y="230">읽기</text>
<text x="574" y="284" text-anchor="middle">SEO / AEO payload</text>
<text x="352" y="368">노출 · 클릭</text>
<text x="124" y="212">CTR &lt; 0.2% → 강등</text>
</g>
<text x="570" y="16" text-anchor="middle" font-family="IBM Plex Mono, monospace"
font-size="10.5" fill="var(--accent)">기존 키워드 주입 — 중복 후보 생성 자체를 억제</text>
</svg>
</div>
<figcaption>
점선 화살표가 이 설계의 핵심이다. 프롬프트에 해당 업종의 기존 키워드를 넣어 중복 후보가 <em>만들어지기 전에</em> 줄이고,
그래도 남는 것만 중복제거 단계가 처리한다. 생성 경로(위)와 서빙 경로(아래)는 Postgres 에서만 만나므로
OpenAI 가 느리거나 죽어도 발행된 사이트의 응답에는 영향이 없다.
</figcaption>
</figure>
</section>
<!-- ======================================================= 3 -->
<section>
<div class="col">
<h2>중복제거 4단계</h2>
<p class="lede">
값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다.
</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1000 500" role="img"
aria-label="LLM 후보 키워드가 금칙어 필터, 정규화 완전 일치, trigram 유사도, 코사인 유사도 순으로 통과하며 각 단계에서 탈락한 것은 차단되거나 기존 키워드의 alias 로 흡수되고, 전부 통과한 것만 새 키워드로 등록된다">
<defs>
<marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="a2w" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--warn)"/>
</marker>
<marker id="a2s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--stop)"/>
</marker>
<marker id="a2acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--accent)"/>
</marker>
</defs>
<text x="440" y="26" text-anchor="middle" font-family="IBM Plex Sans KR, sans-serif"
font-size="12.5" font-weight="600" fill="currentColor">LLM 후보 키워드</text>
<line x1="440" y1="34" x2="440" y2="54" stroke="currentColor" stroke-width="1.4" marker-end="url(#a2)"/>
<!-- stage spine -->
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
<rect x="280" y="60" width="320" height="58" rx="4"/>
<rect x="280" y="150" width="320" height="58" rx="4"/>
<rect x="280" y="240" width="320" height="58" rx="4"/>
<rect x="280" y="330" width="320" height="58" rx="4"/>
</g>
<rect x="280" y="420" width="320" height="58" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<g font-family="IBM Plex Sans KR, sans-serif" fill="currentColor">
<text x="298" y="84" font-size="13" font-weight="600">0 · 금칙어 필터</text>
<text x="298" y="104" font-size="11" opacity=".75">최고 · 1위 · 100% · 완치</text>
<text x="298" y="174" font-size="13" font-weight="600">1 · normalized 완전 일치</text>
<text x="298" y="194" font-size="11" opacity=".75">NFKC · 소문자 · 구두점/공백 제거</text>
<text x="298" y="264" font-size="13" font-weight="600">2 · pg_trgm 유사도 ≥ 0.6</text>
<text x="298" y="284" font-size="11" opacity=".75">표기 변형 · 오타</text>
<text x="298" y="354" font-size="13" font-weight="600">3 · 코사인 유사도 ≥ 0.92</text>
<text x="298" y="374" font-size="11" opacity=".75">의미 중복 — 후보 20건 안에서만</text>
<text x="298" y="444" font-size="13" font-weight="600" fill="var(--accent)">4 · 새 키워드로 INSERT</text>
<text x="298" y="464" font-size="11" fill="var(--accent)" opacity=".9">embedding 저장 · usage_count 1</text>
</g>
<!-- pass-down arrows -->
<g stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#a2)">
<line x1="440" y1="118" x2="440" y2="144"/>
<line x1="440" y1="208" x2="440" y2="234"/>
<line x1="440" y1="298" x2="440" y2="324"/>
<line x1="440" y1="388" x2="440" y2="414"/>
</g>
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".6">
<text x="450" y="137">미일치</text>
<text x="450" y="227">미일치</text>
<text x="450" y="317">미일치</text>
<text x="450" y="407">미일치</text>
</g>
<!-- cost annotations (left) -->
<g font-family="IBM Plex Mono, monospace" font-size="10" fill="currentColor" opacity=".55" text-anchor="end">
<text x="262" y="93">비용 0</text>
<text x="262" y="183">B-tree 1회</text>
<text x="262" y="273">GIN trgm</text>
<text x="262" y="363">HNSW top-20</text>
<text x="262" y="453">INSERT</text>
</g>
<!-- exits -->
<rect x="672" y="66" width="304" height="46" rx="4" fill="var(--stop-bg)" stroke="var(--stop)" stroke-width="1.2"/>
<line x1="600" y1="89" x2="664" y2="89" stroke="var(--stop)" stroke-width="1.4" marker-end="url(#a2s)"/>
<text x="688" y="84" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600" fill="var(--stop)">차단 — 저장하지 않음</text>
<text x="688" y="102" font-family="IBM Plex Mono, monospace" font-size="10.5" fill="var(--stop)" opacity=".9">rejected_banned</text>
<g>
<rect x="672" y="156" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
<rect x="672" y="246" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
<rect x="672" y="336" width="304" height="46" rx="4" fill="var(--warn-bg)" stroke="var(--warn)" stroke-width="1.2"/>
</g>
<g stroke="var(--warn)" stroke-width="1.4" marker-end="url(#a2w)">
<line x1="600" y1="179" x2="664" y2="179"/>
<line x1="600" y1="269" x2="664" y2="269"/>
<line x1="600" y1="359" x2="664" y2="359"/>
</g>
<g font-family="IBM Plex Sans KR, sans-serif" fill="var(--warn)">
<text x="688" y="174" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="192" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리 염색 → 강남 뿌리염색</text>
<text x="688" y="264" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="282" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 뿌리염색약 → 강남 뿌리염색 (0.67)</text>
<text x="688" y="354" font-size="12" font-weight="600">기존 키워드에 alias 흡수</text>
<text x="688" y="372" font-size="10.5" font-family="IBM Plex Mono, monospace" opacity=".9">강남 헤어샵 → 강남 미용실 (0.94)</text>
</g>
<!-- 모든 경로가 합류하는 지점 -->
<rect x="672" y="420" width="304" height="52" rx="4"
fill="none" stroke="currentColor" stroke-width="1.2" stroke-dasharray="5 4" opacity=".8"/>
<line x1="824" y1="382" x2="824" y2="414" stroke="var(--warn)" stroke-width="1.4"
fill="none" marker-end="url(#a2w)"/>
<line x1="600" y1="446" x2="664" y2="446" stroke="var(--accent)" stroke-width="1.4"
fill="none" marker-end="url(#a2acc)"/>
<text x="688" y="443" font-family="IBM Plex Sans KR, sans-serif" font-size="12" font-weight="600"
fill="currentColor">어느 경로든 업체에는 연결된다</text>
<text x="688" y="462" font-family="IBM Plex Mono, monospace" font-size="10.5"
fill="currentColor" opacity=".7">merchant_keyword · relevance · status</text>
</svg>
</div>
<figcaption>
1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 <code>aliases[]</code> 에 흡수한다.
롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이
<code>강남 뿌리염색약</code> 으로 성과를 보고해도 같은 키워드에 매칭된다.
</figcaption>
</figure>
<div class="col">
<h3>실제 로컬 실행 결과</h3>
<p>같은 지역·업종 업체를 순서대로 발행했을 때 <code>npm run smoke</code> 출력이다.</p>
</div>
<pre>1. 레브살롱 (첫 업체) 후보 19 → <b>신규 19</b> / 중복 0
2. 헤어랩 강남점 후보 19 → <b>신규 4</b> / 중복(정확 15, 표기 0, 의미 0)
3. 강남 뷰티랩 후보 16 → <b>신규 3</b> / 중복(정확 12, 표기 1, 의미 0)
matched_exact 강남 뿌리 염색 (sim=1.000 → '강남 뿌리염색')
matched_trigram 강남 뿌리염색약 (sim=0.667 → '강남 뿌리염색')
matched_exact 강남미용실추천 (sim=1.000 → '강남 미용실 추천')</pre>
<div class="col">
<p style="font-size:13.5px;color:var(--muted)">
<span class="pill pill-warn">참고</span>
위 수치는 <code>LLM_PROVIDER=mock</code> 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다.
의미 중복(<code>강남 미용실</code> ↔ <code>강남 헤어샵</code>)은 실제 <code>text-embedding-3-small</code> 로 전환해야 3단계가 발동한다.
</p>
</div>
</section>
<!-- ======================================================= 4 -->
<section>
<div class="col">
<h2>데이터 모델</h2>
<p class="lede">
키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다.
</p>
</div>
<figure>
<div class="fig fig-scroll">
<svg viewBox="0 0 1000 420" role="img"
aria-label="industry 와 region 계층이 keyword 를 분류하고, merchant 는 merchant_keyword 연결 테이블을 통해 전역 keyword 사전을 참조하며, qa_pair 는 merchant 에 직접 매달린다">
<defs>
<marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
</defs>
<g stroke="currentColor" stroke-width="1" fill="var(--surface-2)">
<rect x="24" y="32" width="190" height="62" rx="4"/>
<rect x="24" y="116" width="190" height="62" rx="4"/>
<rect x="24" y="224" width="190" height="104" rx="4"/>
<rect x="380" y="224" width="230" height="104" rx="4"/>
<rect x="720" y="250" width="250" height="90" rx="4"/>
</g>
<rect x="720" y="32" width="250" height="158" rx="4" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<g font-family="IBM Plex Mono, monospace" fill="currentColor">
<text x="40" y="56" font-size="12.5" font-weight="600">industry</text>
<text x="40" y="76" font-size="10.5" opacity=".7">path ltree · beauty.hair</text>
<text x="40" y="140" font-size="12.5" font-weight="600">region</text>
<text x="40" y="160" font-size="10.5" opacity=".7">path ltree · kr.seoul.gangnam</text>
<text x="40" y="250" font-size="12.5" font-weight="600">merchant</text>
<text x="40" y="270" font-size="10.5" opacity=".7">external_id ← 사이트 ID</text>
<text x="40" y="288" font-size="10.5" opacity=".7">description · profile jsonb</text>
<text x="40" y="306" font-size="10.5" opacity=".7">last_generated_at</text>
<text x="396" y="250" font-size="12.5" font-weight="600">merchant_keyword</text>
<text x="396" y="270" font-size="10.5" opacity=".7">relevance · status · source</text>
<text x="396" y="288" font-size="10.5" opacity=".7">impressions · clicks · ctr</text>
<text x="396" y="306" font-size="10.5" opacity=".7">PK (merchant_id, keyword_id)</text>
<text x="736" y="56" font-size="12.5" font-weight="600" fill="var(--accent)">keyword — 전역 사전</text>
<text x="736" y="80" font-size="10.5" fill="var(--accent)" opacity=".9">canonical · 표시용</text>
<text x="736" y="98" font-size="10.5" fill="var(--accent)" opacity=".9">normalized UNIQUE · 판정용</text>
<text x="736" y="116" font-size="10.5" fill="var(--accent)" opacity=".9">aliases text[] · 흡수된 표기</text>
<text x="736" y="134" font-size="10.5" fill="var(--accent)" opacity=".9">embedding vector(1536) HNSW</text>
<text x="736" y="152" font-size="10.5" fill="var(--accent)" opacity=".9">intent · locale</text>
<text x="736" y="170" font-size="10.5" fill="var(--accent)" opacity=".9">usage_count</text>
<text x="736" y="274" font-size="12.5" font-weight="600">qa_pair</text>
<text x="736" y="294" font-size="10.5" opacity=".7">question · answer</text>
<text x="736" y="312" font-size="10.5" opacity=".7">normalized_question UNIQUE</text>
<text x="736" y="330" font-size="10.5" opacity=".7">embedding vector(1536)</text>
</g>
<g stroke="currentColor" stroke-width="1.3" fill="none" marker-end="url(#a3)">
<line x1="214" y1="63" x2="712" y2="63"/>
<line x1="214" y1="147" x2="712" y2="147"/>
<line x1="214" y1="276" x2="372" y2="276"/>
<path d="M610 262 L666 262 L666 111 L712 111"/>
<path d="M119 328 L119 380 L845 380 L845 348"/>
</g>
<g font-family="IBM Plex Mono, monospace" font-size="10.5" fill="currentColor" opacity=".65">
<text x="463" y="56" text-anchor="middle">업종 분류</text>
<text x="463" y="140" text-anchor="middle">지역 분류</text>
<text x="293" y="269" text-anchor="middle">1 : N</text>
<text x="672" y="205">N : 1</text>
<text x="482" y="373" text-anchor="middle">1 : N</text>
</g>
</svg>
</div>
<figcaption>
<code>강남 미용실</code> 을 100개 업체가 쓰더라도 <code>keyword</code> 에는 행이 하나, 임베딩도 하나뿐이다.
업체별 관련도·성과는 전부 <code>merchant_keyword</code> 가 들고 있으므로 사전을 오염시키지 않고
업체마다 다른 순위를 낼 수 있다.
</figcaption>
</figure>
</section>
<!-- ======================================================= 5 -->
<section>
<div class="col">
<h2>API</h2>
<p class="lede">
<code>:id</code> 는 o2o-site-AEO 의 <code>external_id</code> 와 내부 UUID 를 모두 받는다.
연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다.
</p>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr><th style="width:78px">메서드</th><th style="width:300px">경로</th><th>용도</th></tr>
</thead>
<tbody>
<tr><td class="mono">GET</td><td class="mono">/health</td><td>헬스체크 · 현재 LLM provider 확인</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/publish</td><td><strong>사이트 발행 웹훅.</strong> 업체 upsert 후 생성 작업 적재. <code>sync:true</code> 면 동기 실행</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/merchants/:id/generate</td><td>수동 재생성. <code>?sync=true</code> 로 결과를 즉시 확인</td></tr>
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/seo</td><td><strong>발행 사이트가 렌더링 시 호출.</strong> title · description · keywords · tags(alias 포함)</td></tr>
<tr><td class="mono">GET</td><td class="mono">/v1/sites/:id/aeo</td><td>답변엔진용 topics · FAQ · structuredDataHints</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/keywords/search</td><td>어드민 — 자연어 질의로 키워드 사전 벡터 검색</td></tr>
<tr><td class="mono">POST</td><td class="mono">/v1/sites/:id/performance</td><td>노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등</td></tr>
</tbody>
</table>
</div>
<div class="col">
<h3>SEO 응답</h3>
</div>
<pre>$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
{
"title": "레브살롱 | 강남 미용실",
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
"tags": [
{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
"aliases": ["강남미용실"] }
]
}</pre>
<div class="col">
<h3>AEO 응답</h3>
<p>
SEO 가 키워드라면 AEO 는 <strong>질문-답변 쌍과 구조화 데이터</strong>다. AI 검색 크롤러가 인용하는 것은 이쪽이다.
<code>structuredDataHints</code> 는 후속 단계에서 <code>LocalBusiness</code> / <code>FAQPage</code> JSON-LD 로 그대로 매핑되도록
필드를 미리 맞춰 두었다.
</p>
</div>
<pre>{
"topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
"faqs": [
{ "question": "레브살롱은(는) 어디에 있나요?",
"answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
],
"structuredDataHints": {
"type": "LocalBusiness", "name": "레브살롱",
"areaServed": "강남", "category": "미용실"
}
}</pre>
</section>
<!-- ======================================================= 6 -->
<section>
<div class="col">
<h2>기술 선택</h2>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th style="width:130px">레이어</th><th style="width:250px">선택</th><th>이유</th></tr></thead>
<tbody>
<tr><td>런타임</td><td class="mono">NestJS · TypeScript</td><td>o2o-site-AEO 와 payload 타입을 공유할 수 있다</td></tr>
<tr><td>DB</td><td class="mono">PostgreSQL 16 + pgvector<br>+ ltree + pg_trgm</td><td>정확 · 의미 · 계층 조회 3-in-1</td></tr>
<tr><td>DB 접근</td><td class="mono">postgres.js (raw SQL)</td><td>벡터 연산자 <code>&lt;=&gt;</code> 와 <code>ltree</code> 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다</td></tr>
<tr><td>큐 · 스케줄</td><td class="mono">BullMQ + Redis</td><td>60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장</td></tr>
<tr><td>LLM</td><td class="mono">OpenAI Structured Outputs<br>text-embedding-3-small</td><td>JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다</td></tr>
<tr><td>관측</td><td class="mono">generation_run 테이블</td><td>프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다</td></tr>
</tbody>
</table>
</div>
<div class="col">
<h3>로컬 실행</h3>
</div>
<pre>npm install
cp .env.example .env <b># 기본 LLM_PROVIDER=mock — API 키 불필요</b>
npm run db:up <b># postgres(pgvector) + redis</b>
npm run db:migrate &amp;&amp; npm run db:seed
npm start <b># http://localhost:3100</b>
npm run smoke <b># 다른 터미널 — 엔드투엔드 점검</b></pre>
</section>
<!-- ======================================================= 7 -->
<section>
<div class="col">
<h2>남은 작업</h2>
<p class="lede">연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.</p>
<ul class="plain">
<li><span class="pill pill-go">next</span> JSON-LD 조립 — <code>structuredDataHints</code> → <code>LocalBusiness</code> / <code>FAQPage</code> / <code>Service</code></li>
<li><span class="pill pill-go">next</span> <code>/llms.txt</code> 서빙 — AI 검색 크롤러 진입점</li>
<li><span class="pill pill-warn">later</span> 업종 <code>ltree</code> 상위 노드 키워드 상속 (<code>source: 'inherited'</code>)</li>
<li><span class="pill pill-warn">later</span> Redis 응답 캐시 — 서빙은 읽기 99%, TTL 1시간 + 발행 이벤트 무효화</li>
<li><span class="pill pill-warn">later</span> Search Console API 직접 연동 (지금은 <code>/performance</code> 수동 주입)</li>
<li><span class="pill pill-warn">later</span> 키워드 승인 · 차단 어드민 UI</li>
</ul>
</div>
</section>
<footer class="col">
o2o-site-ontology · 설계 문서 · 코드와 함께 <code>docs/architecture.html</code> 에 보관
</footer>
</div>