# -*- 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')