o2o-site-ontology/scripts/build-deck.py
hbyang ef7d51da2c 설계 문서 PPTX 덱 추가
docs/architecture.html 내용을 발표용 11장 덱으로 재구성.
전체 흐름·중복제거 4단계·데이터 모델 도식을 이미지가 아니라
네이티브 도형으로 그려 PowerPoint 에서 그대로 편집 가능하다.

- scripts/build-deck.py (python-pptx) 로 재생성 가능
- 슬라이드 경계 이탈 0건, 텍스트 가로 넘침 0건 검증

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

525 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.
python3 scripts/build-deck.py
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.oxml.ns import qn
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
INK = RGBColor(0x10, 0x18, 0x19)
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
MUTED = RGBColor(0x63, 0x75, 0x7A)
LINE = RGBColor(0xD5, 0xDC, 0xDB)
BG = RGBColor(0xF4, 0xF6, 0xF5)
SURFACE = RGBColor(0xFF, 0xFF, 0xFF)
SURF2 = RGBColor(0xEC, 0xEF, 0xF0)
ACCENT = RGBColor(0x0D, 0x6A, 0x60)
ACC_BG = RGBColor(0xDF, 0xF0, 0xEC)
WARN = RGBColor(0x8A, 0x5A, 0x06)
WARN_BG = RGBColor(0xF6, 0xEA, 0xD2)
STOP = RGBColor(0x9D, 0x3A, 0x30)
STOP_BG = RGBColor(0xF6, 0xE0, 0xDC)
SANS = 'Apple SD Gothic Neo' # macOS 기본 한글 산세리프
MONO = 'Menlo'
W, H = 13.333, 7.5
MX = 0.75 # 좌우 여백
# ---------------------------------------------------------------- 저수준 헬퍼
def _ea(run, name):
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
rPr = run._r.get_or_add_rPr()
for tag in ('a:ea', 'a:cs'):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set('typeface', name)
def write(tf, lines, space=2):
"""lines: [(text, size, bold, color, font)] — 첫 줄은 기존 문단 재사용."""
tf.word_wrap = True
for i, spec in enumerate(lines):
text, size, bold, color = spec[0], spec[1], spec[2], spec[3]
font = spec[4] if len(spec) > 4 else SANS
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.space_after = Pt(space)
p.line_spacing = 1.15
r = p.add_run()
r.text = text
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
r.font.name = font
_ea(r, font if font != MONO else SANS)
return tf
def textbox(sl, x, y, w, h, lines, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, space=2):
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = tb.text_frame
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
tf.vertical_anchor = anchor
write(tf, lines, space)
for p in tf.paragraphs:
p.alignment = align
return tb
def box(sl, x, y, w, h, lines=None, fill=SURF2, line=LINE, lw=1.0,
pad=0.12, anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT, space=2, rounded=True):
shape = sl.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
Inches(x), Inches(y), Inches(w), Inches(h))
if fill is None:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = fill
if line is None:
shape.line.fill.background()
else:
shape.line.color.rgb = line
shape.line.width = Pt(lw)
if rounded:
shape.adjustments[0] = 0.09
shape.shadow.inherit = False
tf = shape.text_frame
tf.margin_left = tf.margin_right = Inches(pad)
tf.margin_top = tf.margin_bottom = Inches(pad * 0.7)
tf.vertical_anchor = anchor
if lines:
write(tf, lines, space)
for p in tf.paragraphs:
p.alignment = align
return shape
def arrow(sl, x1, y1, x2, y2, color=INK, width=1.25, dash=False):
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
c.line.color.rgb = color
c.line.width = Pt(width)
ln = c.line._get_or_add_ln()
if dash:
ln.append(ln.makeelement(qn('a:prstDash'), {'val': 'dash'}))
ln.append(ln.makeelement(qn('a:tailEnd'), {'type': 'triangle', 'w': 'med', 'len': 'med'}))
return c
def elbow(sl, pts, color=INK, width=1.25, dash=False):
"""직교 경로: 마지막 구간에만 화살촉."""
for i in range(len(pts) - 1):
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
if i == len(pts) - 2:
arrow(sl, x1, y1, x2, y2, color, width, dash)
else:
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT,
Inches(x1), Inches(y1), Inches(x2), Inches(y2))
c.line.color.rgb = color
c.line.width = Pt(width)
if dash:
c.line._get_or_add_ln().append(
c.line._get_or_add_ln().makeelement(qn('a:prstDash'), {'val': 'dash'}))
def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w=2.4):
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
# ---------------------------------------------------------------- 슬라이드 골격
prs = Presentation()
prs.slide_width = Inches(W)
prs.slide_height = Inches(H)
BLANK = prs.slide_layouts[6]
def slide(title=None, lede=None, eyebrow=None):
sl = prs.slides.add_slide(BLANK)
bg = sl.background.fill
bg.solid()
bg.fore_color.rgb = BG
y = 0.42
if eyebrow:
textbox(sl, MX, y, 8, 0.22, [(eyebrow.upper(), 9, True, ACCENT, MONO)])
y += 0.30
if title:
textbox(sl, MX, y, W - 2 * MX, 0.5, [(title, 26, True, INK)])
y += 0.62
if lede:
textbox(sl, MX, y, W - 2 * MX - 1.2, 0.4, [(lede, 12.5, False, MUTED)])
y += 0.46
return sl, y + 0.22
def table(sl, x, y, w, cols, rows, widths=None, fs=10.5, hfs=8.5, rowh=0.34):
shape = sl.shapes.add_table(len(rows) + 1, len(cols), Inches(x), Inches(y),
Inches(w), Inches(rowh * (len(rows) + 1)))
t = shape.table
t.first_row = False
if widths:
for i, ww in enumerate(widths):
t.columns[i].width = Inches(ww)
for i, c in enumerate(cols):
cell = t.cell(0, i)
cell.fill.solid(); cell.fill.fore_color.rgb = BG
cell.margin_left = cell.margin_right = Inches(0.09)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
write(cell.text_frame, [(c.upper(), hfs, True, MUTED, MONO)])
for r, row in enumerate(rows, start=1):
t.rows[r].height = Inches(rowh)
for i, val in enumerate(row):
cell = t.cell(r, i)
cell.fill.solid(); cell.fill.fore_color.rgb = SURFACE
cell.margin_left = cell.margin_right = Inches(0.09)
cell.margin_top = cell.margin_bottom = Inches(0.04)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
mono = val.startswith('`')
write(cell.text_frame,
[(val.lstrip('`'), fs, False, INK_SOFT, MONO if mono else SANS)])
return t
def footer(sl, n):
textbox(sl, MX, H - 0.52, 6, 0.24,
[('o2o-site-ontology', 8.5, False, MUTED, MONO)])
textbox(sl, W - MX - 1.2, H - 0.52, 1.2, 0.24,
[(f'{n:02d}', 8.5, False, MUTED, MONO)], align=PP_ALIGN.RIGHT)
# ================================================================ 1 표지
sl = prs.slides.add_slide(BLANK)
sl.background.fill.solid(); sl.background.fill.fore_color.rgb = BG
box(sl, 0, 0, 0.16, H, fill=ACCENT, line=None, rounded=False)
textbox(sl, 1.3, 1.95, 10, 0.3, [('O2O-SITE-ONTOLOGY', 10.5, True, ACCENT, MONO)])
textbox(sl, 1.3, 2.35, 11, 1.5,
[('발행 사이트에 붙는', 40, True, INK), ('SEO/AEO 키워드 온톨로지', 40, True, INK)], space=4)
textbox(sl, 1.3, 4.15, 8.6, 1.0,
[('업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다.', 13, False, INK_SOFT),
('LLM 이 후보를 만들고, 4단계 중복제거가 전역 사전을 깨끗하게 유지하고,', 13, False, INK_SOFT),
('발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.', 13, False, INK_SOFT)], space=3)
for i, chip in enumerate(['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs']):
wch = 0.16 + len(chip) * 0.082
box(sl, 1.3 + sum(0.16 + len(c) * 0.082 + 0.14 for c in
['PostgreSQL 16 + pgvector', 'NestJS', 'BullMQ', 'OpenAI Structured Outputs'][:i]),
5.5, wch, 0.32, [(chip, 9, False, MUTED, MONO)],
fill=SURFACE, line=LINE, pad=0.08, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER)
textbox(sl, 1.3, 6.5, 8, 0.24, [('설계 문서 · 로컬 구현 검증 완료', 9.5, False, MUTED, MONO)])
# ================================================================ 2 DB 선택
sl, y = slide('일반 DB 냐 벡터 DB 냐', '둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.', '설계 판단 1')
table(sl, MX, y, W - 2 * MX,
['조회 유형', '실제 질의', '필요한 것'],
[['정확 조회', '업체 A 의 활성 키워드 20개', '`B-tree / 관계형 조인'],
['의미 조회', '이 후보가 기존 키워드와 의미상 겹치는가', '`vector (HNSW)'],
['관계 탐색', '업종 트리 상위에서 물려받을 공통 키워드', '`ltree 계층 / recursive CTE']],
widths=[2.3, 5.9, 3.633], rowh=0.42)
yy = y + 2.0
box(sl, MX, yy, 0.06, 1.55, fill=ACCENT, line=None, rounded=False)
box(sl, MX + 0.06, yy, W - 2 * MX - 0.06, 1.55,
[('결론 — PostgreSQL 하나로 시작한다.', 14, True, ACCENT),
('pgvector + ltree + pg_trgm + JSONB 로 세 가지가 한 엔진 안에서 해결되고, 무엇보다', 11.5, False, INK_SOFT),
('키워드 조회에는 항상 "어느 업체의" 라는 조인이 따라붙는다.', 11.5, True, INK_SOFT),
('', 6, False, INK_SOFT),
('전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. 벡터 행이 1천만 건을', 11.5, False, INK_SOFT),
('넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.', 11.5, False, INK_SOFT)],
fill=ACC_BG, line=None, pad=0.24, space=3, rounded=False)
footer(sl, 2)
# ================================================================ 3 전체 흐름
sl, y = slide('전체 흐름', '생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.', '아키텍처')
BW, BH = 2.15, 1.05
xs = [MX, MX + 2.5, MX + 5.0, MX + 7.5, MX + 10.0]
r1 = y + 0.42
box(sl, xs[0], r1, BW, BH,
[('트리거', 11.5, True, INK), ('사이트 발행 — 즉시', 9, False, MUTED),
('크론 03:00 — 30일 경과', 9, False, MUTED), ('성과 저조 — 재생성', 9, False, MUTED)], space=1)
box(sl, xs[1], r1, BW, BH,
[('BullMQ 큐', 11.5, True, INK), ('60초 dedupe 창', 9, False, MUTED),
('재시도 3회 · 백오프', 9, False, MUTED), ('동시성 2', 9, False, MUTED)], space=1)
box(sl, xs[2], r1, BW, BH,
[('생성 워커', 11.5, True, INK), ('OpenAI · gpt-4.1-mini', 9, False, MUTED),
('Structured Outputs', 9, False, MUTED), ('임베딩 배치 1회', 9, False, MUTED)], space=1)
box(sl, xs[3], r1, BW, BH,
[('중복제거 4단계', 11.5, True, WARN), ('해시 → trigram → 벡터', 9, False, WARN),
('미일치만 신규 등록', 9, False, WARN), ('나머지는 alias 흡수', 9, False, WARN)],
fill=WARN_BG, line=WARN, lw=1.4, space=1)
box(sl, xs[4], r1 - 0.14, 2.58, BH + 0.28,
[('PostgreSQL 16', 11.5, True, ACCENT), ('pgvector · ltree · pg_trgm', 9, False, ACCENT),
('keyword (전역 사전)', 9, False, ACCENT), ('merchant_keyword', 9, False, ACCENT),
('qa_pair · generation_run', 9, False, ACCENT)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
mid = r1 + BH / 2
for i in range(4):
a, b = xs[i] + BW, xs[i + 1]
arrow(sl, a + 0.04, mid, b - 0.04, mid)
for i, t in enumerate(['적재', 'job', '후보', 'write']):
label(sl, xs[i] + BW + 0.02, mid - 0.28, t, 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=0.42)
r2 = r1 + 2.05
box(sl, xs[3], r2, BW, BH,
[('Serving API', 11.5, True, INK), ('GET /v1/sites/:id/seo', 9, False, MUTED),
('GET /v1/sites/:id/aeo', 9, False, MUTED), ('읽기 99% · 캐시 대상', 9, False, MUTED)], space=1)
box(sl, xs[1], r2, BW, BH,
[('발행된 사이트', 11.5, True, INK), ('o2o-site-AEO', 9, False, MUTED),
('렌더링 시 호출', 9, False, MUTED)], space=1)
box(sl, xs[1], r2 + 1.5, 4.65, 0.6,
[('성과 수집 · Search Console · 서치어드바이저 · 유입 로그', 9.5, False, INK)],
anchor=MSO_ANCHOR.MIDDLE, space=1)
elbow(sl, [(xs[4] + 1.29, r1 - 0.14 + BH + 0.28), (xs[4] + 1.29, r2 + BH / 2), (xs[3] + BW + 0.04, r2 + BH / 2)])
label(sl, xs[4] + 1.35, r2 - 0.05, '읽기', 8.5, w=0.6)
arrow(sl, xs[3] - 0.04, r2 + BH / 2, xs[1] + BW + 0.04, r2 + BH / 2)
label(sl, xs[1] + BW + 0.5, r2 + BH / 2 - 0.28, 'SEO / AEO payload', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.6)
arrow(sl, xs[1] + BW / 2, r2 + BH, xs[1] + BW / 2, r2 + 1.46)
label(sl, xs[1] + BW / 2 + 0.08, r2 + BH + 0.06, '노출 · 클릭', 8.5)
elbow(sl, [(xs[1], r2 + 1.8), (MX + 0.5, r2 + 1.8), (MX + 0.5, r1 + BH + 0.06)])
label(sl, MX + 0.58, r2 + 0.9, 'CTR < 0.2% → 강등', 8.5)
elbow(sl, [(xs[4] + 1.29, r1 - 0.18), (xs[4] + 1.29, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.5), (xs[2] + BW / 2, r1 - 0.04)],
color=ACCENT, dash=True)
label(sl, xs[2] + BW / 2, r1 - 0.78, '기존 키워드 주입 — 중복 후보 생성 자체를 억제', 9, ACCENT, MONO, PP_ALIGN.CENTER, w=4.6)
footer(sl, 3)
# ================================================================ 4 중복제거
sl, y = slide('중복제거 4단계', '값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어난다.', '핵심 메커니즘')
SX, SW, SH, GAP = 3.55, 3.5, 0.66, 0.19
stages = [
('0 · 금칙어 필터', '최고 · 1위 · 100% · 완치', '비용 0', STOP, STOP_BG, '차단 — 저장하지 않음', 'rejected_banned'),
('1 · normalized 완전 일치', 'NFKC · 소문자 · 공백/구두점 제거', 'B-tree 1회', WARN, WARN_BG, 'alias 흡수', '강남 뿌리 염색 → 강남 뿌리염색'),
('2 · pg_trgm 유사도 ≥ 0.6', '표기 변형 · 오타', 'GIN trgm', WARN, WARN_BG, 'alias 흡수', '강남 뿌리염색약 → 강남 뿌리염색 (0.67)'),
('3 · 코사인 유사도 ≥ 0.92', '의미 중복 — 후보 20건 안에서만', 'HNSW top-20', WARN, WARN_BG, 'alias 흡수', '강남 헤어샵 → 강남 미용실 (0.94)'),
]
top = y + 0.28
label(sl, SX, top - 0.30, 'LLM 후보 키워드', 11, INK, SANS, PP_ALIGN.CENTER, w=SW)
for i, (t, sub, cost, col, colbg, exit_t, exit_s) in enumerate(stages):
yy = top + i * (SH + GAP)
box(sl, SX, yy, SW, SH, [(t, 11, True, INK), (sub, 8.5, False, MUTED)], space=1)
label(sl, SX - 1.55, yy + 0.20, cost, 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
arrow(sl, SX + SW + 0.04, yy + SH / 2, SX + SW + 0.7, yy + SH / 2, color=col)
box(sl, SX + SW + 0.74, yy, 4.3, SH,
[(exit_t, 10, True, col), (exit_s, 8.5, False, col, MONO)],
fill=colbg, line=col, lw=1.1, space=1)
if i < len(stages) - 1:
arrow(sl, SX + SW / 2, yy + SH, SX + SW / 2, yy + SH + GAP - 0.02)
last = top + len(stages) * (SH + GAP)
arrow(sl, SX + SW / 2, last - GAP, SX + SW / 2, last - 0.02)
box(sl, SX, last, SW, SH,
[('4 · 새 키워드로 INSERT', 11, True, ACCENT), ('embedding 저장 · usage_count 1', 8.5, False, ACCENT)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
label(sl, SX - 1.55, last + 0.20, 'INSERT', 8.5, MUTED, MONO, PP_ALIGN.RIGHT, w=1.45)
box(sl, SX + SW + 0.74, last, 4.3, SH,
[('어느 경로든 업체에는 연결된다', 10, True, INK), ('merchant_keyword · relevance · status', 8.5, False, MUTED, MONO)],
fill=None, line=INK, lw=1.0, space=1)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다 — 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지된다.',
10, False, MUTED)])
footer(sl, 4)
# ================================================================ 5 데이터 모델
sl, y = slide('데이터 모델', '키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거가 성립하지 않는다.', '스키마')
c1, c2, c3 = MX, MX + 4.7, MX + 8.9
box(sl, c1, y + 0.15, 2.6, 0.62, [('industry', 10.5, True, INK, MONO), ('path ltree · beauty.hair', 8.5, False, MUTED, MONO)], space=1)
box(sl, c1, y + 0.97, 2.6, 0.62, [('region', 10.5, True, INK, MONO), ('path ltree · kr.jeonbuk.gunsan', 8.5, False, MUTED, MONO)], space=1)
box(sl, c1, y + 2.35, 2.6, 1.15,
[('merchant', 10.5, True, INK, MONO), ('external_id ← 사이트 ID', 8.5, False, MUTED, MONO),
('description · profile jsonb', 8.5, False, MUTED, MONO), ('last_generated_at', 8.5, False, MUTED, MONO)], space=1)
box(sl, c2, y + 2.35, 3.1, 1.15,
[('merchant_keyword', 10.5, True, INK, MONO), ('relevance · status · source', 8.5, False, MUTED, MONO),
('impressions · clicks · ctr', 8.5, False, MUTED, MONO), ('PK (merchant_id, keyword_id)', 8.5, False, MUTED, MONO)], space=1)
box(sl, c3, y + 0.15, 3.6, 1.95,
[('keyword — 전역 사전', 10.5, True, ACCENT, MONO), ('canonical · 표시용', 8.5, False, ACCENT, MONO),
('normalized UNIQUE · 판정용', 8.5, False, ACCENT, MONO), ('aliases text[] · 흡수된 표기', 8.5, False, ACCENT, MONO),
('embedding vector(1536) HNSW', 8.5, False, ACCENT, MONO), ('intent · locale · usage_count', 8.5, False, ACCENT, MONO)],
fill=ACC_BG, line=ACCENT, lw=1.4, space=1)
box(sl, c3, y + 2.55, 3.6, 0.95,
[('qa_pair', 10.5, True, INK, MONO), ('question · answer', 8.5, False, MUTED, MONO),
('normalized_question UNIQUE', 8.5, False, MUTED, MONO), ('embedding vector(1536)', 8.5, False, MUTED, MONO)], space=1)
arrow(sl, c1 + 2.64, y + 0.46, c3 - 0.04, y + 0.55)
label(sl, c1 + 3.0, y + 0.18, '업종 분류', 8.5)
arrow(sl, c1 + 2.64, y + 1.28, c3 - 0.04, y + 1.20)
label(sl, c1 + 3.0, y + 1.32, '지역 분류', 8.5)
arrow(sl, c1 + 2.64, y + 2.92, c2 - 0.04, y + 2.92)
label(sl, c1 + 2.75, y + 2.62, '1 : N', 8.5, MUTED, MONO, PP_ALIGN.CENTER, w=1.9)
elbow(sl, [(c2 + 3.14, y + 2.75), (c3 - 0.35, y + 2.75), (c3 - 0.35, y + 1.1), (c3 - 0.04, y + 1.1)])
label(sl, c3 - 0.95, y + 1.85, 'N : 1', 8.5)
elbow(sl, [(c1 + 1.3, y + 3.54), (c1 + 1.3, y + 3.95), (c3 + 1.8, y + 3.95), (c3 + 1.8, y + 3.54)])
label(sl, c2 + 1.3, y + 3.62, '1 : N', 8.5)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있다.',
10, False, MUTED)])
footer(sl, 5)
# ================================================================ 6 API
sl, y = slide('API', ':id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다 — 연동 쪽에 ID 매핑 테이블이 필요 없다.', '연동 표면')
table(sl, MX, y, W - 2 * MX,
['메서드', '경로', '용도'],
[['`GET', '`/health', '헬스체크 · 현재 LLM provider 확인'],
['`POST', '`/v1/merchants/publish', '사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재 (sync:true 면 동기)'],
['`POST', '`/v1/merchants/:id/generate', '수동 재생성. ?sync=true&count=N'],
['`GET', '`/v1/sites/:id/seo', '발행 사이트가 렌더링 시 호출. title · description · keywords · tags'],
['`GET', '`/v1/sites/:id/aeo', '답변엔진용 topics · FAQ · structuredDataHints'],
['`POST', '`/v1/keywords/search', '어드민 — 자연어 질의로 키워드 사전 벡터 검색'],
['`POST', '`/v1/sites/:id/performance', '노출·클릭 주입 → CTR 갱신 → 저성과 강등']],
widths=[1.0, 3.5, 7.333], rowh=0.4)
box(sl, MX, y + 3.35, 5.75, 1.85,
[('SEO 응답', 10, True, ACCENT, MONO),
('{', 9.5, False, INK_SOFT, MONO),
(' "title": "스테이머뭄 | 군산 펜션",', 9.5, False, INK_SOFT, MONO),
(' "keywords": ["스테이머뭄", "군산 펜션", …],', 9.5, False, INK_SOFT, MONO),
(' "tags": [{ "keyword": "군산 애견동반 펜션",', 9.5, False, INK_SOFT, MONO),
(' "relevance": 0.88, "aliases": [ … ] }]', 9.5, False, INK_SOFT, MONO),
('}', 9.5, False, INK_SOFT, MONO)],
fill=SURFACE, space=1)
box(sl, MX + 6.05, y + 3.35, 5.78, 1.85,
[('AEO 응답 — 답변엔진이 인용하는 쪽', 10, True, ACCENT, MONO),
('{', 9.5, False, INK_SOFT, MONO),
(' "topics": ["군산 펜션", "군산 커플 펜션"],', 9.5, False, INK_SOFT, MONO),
(' "faqs": [{ "question": "…근처에 가볼 만한 곳은?",', 9.5, False, INK_SOFT, MONO),
(' "answer": "선유도, 은파호수공원 …" }],', 9.5, False, INK_SOFT, MONO),
(' "structuredDataHints": { "type": "LocalBusiness" }', 9.5, False, INK_SOFT, MONO),
('}', 9.5, False, INK_SOFT, MONO)],
fill=SURFACE, space=1)
footer(sl, 6)
# ================================================================ 7 기술 선택
sl, y = slide('기술 선택', None, '스택')
table(sl, MX, y, W - 2 * MX,
['레이어', '선택', '이유'],
[['런타임', '`NestJS · TypeScript', 'o2o-site-AEO 와 payload 타입을 공유할 수 있다'],
['DB', '`PostgreSQL 16 + pgvector + ltree + pg_trgm', '정확 · 의미 · 계층 조회 3-in-1'],
['DB 접근', '`postgres.js (raw SQL)', '벡터 연산자와 ltree 는 어차피 raw SQL — ORM 을 얹으면 우회 코드가 더 는다'],
['큐 · 스케줄', '`BullMQ + Redis', '60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장'],
['LLM', '`OpenAI Structured Outputs / text-embedding-3-small', 'JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다'],
['관측', '`generation_run 테이블', '프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다']],
widths=[1.5, 4.6, 5.733], rowh=0.44)
box(sl, MX, y + 3.5, W - 2 * MX, 1.5,
[('로컬 실행', 10, True, ACCENT, MONO),
('npm install && cp .env.example .env # 기본 LLM_PROVIDER=mock — API 키 불필요', 10, False, INK_SOFT, MONO),
('npm run db:up && npm run db:migrate && npm run db:seed', 10, False, INK_SOFT, MONO),
('npm start # http://localhost:3100', 10, False, INK_SOFT, MONO),
('npm run smoke # 다른 터미널 — 엔드투엔드 점검', 10, False, INK_SOFT, MONO)],
fill=SURFACE, space=2)
footer(sl, 7)
# ================================================================ 8 검증 1
sl, y = slide('로컬 검증 — 같은 지역·업종 3곳', '강남/미용실 업체를 순서대로 발행했을 때 중복제거가 실제로 어떻게 걸리는지.', '검증 1')
table(sl, MX, y, 7.4,
['순서', '업체', '후보', '신규', '중복 (정확/표기/의미)'],
[['1', '레브살롱', '19', '19', '0 / 0 / 0'],
['2', '헤어랩 강남점', '19', '4', '15 / 0 / 0'],
['3', '강남 뷰티랩', '16', '3', '12 / 1 / 0']],
widths=[0.7, 2.4, 1.0, 1.0, 2.3], rowh=0.42)
box(sl, MX, y + 2.1, 7.4, 1.5,
[('matched_exact 강남 뿌리 염색 (sim=1.000 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
('matched_trigram 강남 뿌리염색약 (sim=0.667 → \'강남 뿌리염색\')', 10, False, INK_SOFT, MONO),
('matched_exact 강남미용실추천 (sim=1.000 → \'강남 미용실 추천\')', 10, False, INK_SOFT, MONO)],
fill=SURFACE, space=3)
box(sl, MX + 7.8, y, 4.03, 3.6,
[('세 번째 업체에서는', 11, False, MUTED),
('16개 중 3개만', 22, True, ACCENT),
('새 키워드였다', 11, False, MUTED),
('', 8, False, MUTED),
('나머지 13개는 이미 사전에 있던', 10.5, False, INK_SOFT),
('키워드에 흡수됐다. 업체가 늘어도', 10.5, False, INK_SOFT),
('사전은 선형으로 늘지 않는다.', 10.5, False, INK_SOFT)],
fill=SURFACE, pad=0.24, space=4)
textbox(sl, MX, H - 1.0, W - 2 * MX, 0.4,
[('참고 — LLM_PROVIDER=mock 기준. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. 의미 중복은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.',
9.5, False, MUTED)])
footer(sl, 8)
# ================================================================ 9 검증 2
sl, y = slide('로컬 검증 — 군산 스테이머뭄 200개', '펜션 한 곳으로 키워드 200개를 뽑아 pgvector 에 적재했을 때 실제로 무엇이 쌓이는가.', '검증 2')
box(sl, MX, y, 5.6, 0.95,
[('후보 200 → 신규 174 / 중복(표기 26) / 연결 174 / QA 5 799ms', 10.5, False, INK_SOFT, MONO),
('keyword 174행 · 임베딩 174건 · 흡수된 표기 26개', 10.5, False, ACCENT, MONO)],
fill=SURFACE, space=3)
textbox(sl, MX, y + 1.25, 5.6, 0.3, [('relevance 분포', 11.5, True, INK)])
dist = [('0.93~0.99', 4, '브랜드 · 핵심', ACCENT),
('0.80~0.88', 32, '지역 × 업종 × 동반자', ACCENT),
('0.72~0.76', 11, '시즌', INK_SOFT),
('0.60~0.70', 51, '시설 · 서비스', INK_SOFT),
('0.50', 6, '질문형', MUTED),
('0.42', 40, '동반자 × 시설', STOP),
('0.38', 30, '동반자 × 서비스', STOP)]
by = y + 1.62
for i, (rng, n, note, col) in enumerate(dist):
yy = by + i * 0.36
label(sl, MX, yy + 0.03, rng, 9, MUTED, MONO, PP_ALIGN.RIGHT, w=0.95)
box(sl, MX + 1.05, yy, max(0.06, n * 0.048), 0.24, fill=col, line=None, rounded=False)
label(sl, MX + 1.05 + max(0.06, n * 0.048) + 0.1, yy + 0.03, f'{n} {note}', 9, col, SANS, w=3.2)
box(sl, MX + 7.4, y, 4.43, 2.05,
[('하위 70개는 이런 것들', 11, True, STOP),
('애견동반 바베큐장 0.38', 10, False, INK_SOFT, MONO),
('태교여행 프라이빗 스파 0.38', 10, False, INK_SOFT, MONO),
('커플 바베큐장 0.38', 10, False, INK_SOFT, MONO),
('', 6, False, MUTED),
('문법은 맞지만 아무도 이렇게 검색하지 않는다.', 10, False, MUTED)],
fill=STOP_BG, line=STOP, lw=1.1, space=2)
box(sl, MX + 7.4, y + 2.35, 4.43, 1.9,
[('잘 작동한 부분 — 벡터 검색', 11, True, ACCENT),
('"선유도 근처 바베큐 되는 펜션"', 10, False, INK_SOFT, MONO),
(' 0.686 선유도 근처 펜션', 10, False, ACCENT, MONO),
(' 0.439 고군산군도 근처 펜션', 10, False, ACCENT, MONO),
(' 0.392 선유도 펜션 추천', 10, False, ACCENT, MONO)],
fill=SURFACE, space=2)
footer(sl, 9)
# ================================================================ 10 발견
sl, y = slide('발견 — 저장한 것의 89%는 쓰이지 않는다', None, '문제 정의')
box(sl, MX, y, 5.3, 2.5,
[('적재된 키워드 174개 중', 12, False, MUTED),
('89%', 62, True, STOP),
('가 한 번도 서빙되지 않는다 (서빙 20개 / 사장 154개)', 11.5, False, INK_SOFT)],
fill=STOP_BG, line=None, pad=0.3, space=6, anchor=MSO_ANCHOR.MIDDLE)
box(sl, MX + 5.7, y, 6.13, 2.5,
[('그런데 이 154개는', 12, True, INK),
('· 매번 dedup 후보 검색 대상이고', 11.5, False, INK_SOFT),
('· HNSW 인덱스에 들어가 있고', 11.5, False, INK_SOFT),
('· 다음 생성 때 프롬프트에도 실린다', 11.5, False, INK_SOFT),
('', 6, False, MUTED),
('순수한 부채다. 주기 생성을 30일마다 돌리면 매달 반복된다.', 11.5, True, STOP)],
fill=SURFACE, pad=0.3, space=5)
textbox(sl, MX, y + 2.85, W - 2 * MX, 0.3, [('시간이 지나면 실제로 바뀌는 건 3가지뿐', 14, True, INK)])
table(sl, MX, y + 3.3, W - 2 * MX,
['무엇이 바뀌나', '올바른 대응', '현행 설계'],
[['업체 정보 (메뉴 추가, 이전, 서비스 변경)', '이벤트 기반 재생성', '30일 크론이 대신 처리'],
['성과 데이터 누적', '재순위 — 생성이 아님', '재생성으로 오해'],
['계절 · 트렌드 (연말 파티헤어, 여름 네일)', '업종 단위 생성 — 업체 수와 무관', '업체마다 중복 생성']],
widths=[4.6, 4.0, 3.233], rowh=0.4)
footer(sl, 10)
# ================================================================ 11 개선
sl, y = slide('개선 방향', '키워드는 업체 수 × 시간이 아니라 업체 수에만 비례해야 한다.', '다음 단계')
items = [
('relevance 컷', '0.6 미만 후보는 저장하지 않는다', '200개 → 110개. 저장조차 하지 말아야 할 것들.'),
('업체당 정원제', 'active 슬롯 30개 고정', '새 후보는 최약체와 경쟁해서 이겨야 들어온다.\n시스템이 스스로 상한을 갖는다.'),
('profile_hash', '업체 정보가 바뀔 때만 재생성', '정보가 그대로면 재생성해서 얻을 게 없다. 주기 크론이 사실상 무력화된다.'),
('크론 성격 전환', '생성 → 정리', '고아 키워드 삭제, 저성과 강등. 늘리는 일이 아니라 줄이는 일.'),
]
for i, (t, s, d) in enumerate(items):
yy = y + i * 0.85
box(sl, MX, yy, 0.42, 0.68, [(str(i + 1), 12, True, ACCENT, MONO)],
fill=SURFACE, anchor=MSO_ANCHOR.MIDDLE, align=PP_ALIGN.CENTER, pad=0.02)
textbox(sl, MX + 0.62, yy + 0.02, 2.5, 0.3, [(t, 13, True, INK)])
textbox(sl, MX + 3.2, yy + 0.04, 3.0, 0.3, [(s, 11, False, ACCENT, MONO)])
textbox(sl, MX + 6.4, yy + 0.02, 5.4, 0.6,
[(ln, 10.5, False, MUTED) for ln in d.split('\n')], space=1)
box(sl, MX, y + 3.7, W - 2 * MX, 1.5,
[('그 뒤에 남은 작업', 11, True, ACCENT),
('JSON-LD 조립 (structuredDataHints → LocalBusiness / FAQPage / Service) · /llms.txt 서빙', 11, False, INK_SOFT),
('업종 ltree 상위 노드 키워드 상속 · Redis 응답 캐시 · Search Console API 직접 연동 · 키워드 승인/차단 어드민', 11, False, INK_SOFT)],
fill=ACC_BG, line=None, pad=0.26, space=4)
footer(sl, 11)
prs.save('docs/architecture.pptx')
print('✅ docs/architecture.pptx')