diff --git a/README.md b/README.md index 322cd6f..280d18a 100644 --- a/README.md +++ b/README.md @@ -130,4 +130,11 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8' ## 아키텍처 도식 -`docs/architecture.html` 을 브라우저로 열면 전체 흐름·중복제거 단계·데이터 모델을 볼 수 있다. +| 파일 | 용도 | +|---|---| +| `docs/architecture.html` | 브라우저용 설계 문서 — 전체 흐름 · 중복제거 단계 · 데이터 모델 | +| `docs/architecture.pptx` | 발표용 11장 덱. 도식은 이미지가 아니라 네이티브 도형이라 PowerPoint 에서 바로 편집된다 | + +덱은 `python3 scripts/build-deck.py` 로 다시 생성한다 (`pip install python-pptx` 필요). +한글 폰트는 `Apple SD Gothic Neo`, 코드는 `Menlo` 로 지정되어 있다 — Windows 에서 열 때는 +`scripts/build-deck.py` 상단의 `SANS` / `MONO` 를 `맑은 고딕` / `Consolas` 로 바꿔 다시 생성하면 된다. diff --git a/docs/architecture.pptx b/docs/architecture.pptx new file mode 100644 index 0000000..0c3216d Binary files /dev/null and b/docs/architecture.pptx differ diff --git a/scripts/build-deck.py b/scripts/build-deck.py new file mode 100644 index 0000000..35770fe --- /dev/null +++ b/scripts/build-deck.py @@ -0,0 +1,524 @@ +# -*- 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')