From ef7d51da2c9f2ab1b217a84c4dbc52eedd4b319a Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 9 Sep 2026 13:50:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20PPTX?= =?UTF-8?q?=20=EB=8D=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/architecture.html 내용을 발표용 11장 덱으로 재구성. 전체 흐름·중복제거 4단계·데이터 모델 도식을 이미지가 아니라 네이티브 도형으로 그려 PowerPoint 에서 그대로 편집 가능하다. - scripts/build-deck.py (python-pptx) 로 재생성 가능 - 슬라이드 경계 이탈 0건, 텍스트 가로 넘침 0건 검증 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 +- docs/architecture.pptx | Bin 0 -> 58519 bytes scripts/build-deck.py | 524 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 docs/architecture.pptx create mode 100644 scripts/build-deck.py 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 0000000000000000000000000000000000000000..0c3216d329eaf3ac7f9eaf30f733fa52fde4f2ea GIT binary patch literal 58519 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૫h2M5zQ;n(-Hc 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 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')