o2o-site-ontology

발행 사이트에 붙는
SEO/AEO 키워드 온톨로지

업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다. LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고, 발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.

PostgreSQL 16 + pgvector NestJS BullMQ OpenAI Structured Outputs

일반 DB 냐 벡터 DB 냐

둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.

조회 유형실제 질의필요한 것
정확 조회 업체 A 의 활성 키워드 20개 B-tree / 관계형 조인
의미 조회 이 후보가 기존 키워드와 의미상 겹치는가 vector (HNSW)
관계 탐색 업종 트리 상위에서 물려받을 공통 키워드 ltree 계층 / recursive CTE

결론 — PostgreSQL 하나로 시작한다. pgvector + ltree + pg_trgm + JSONB 로 세 가지가 모두 한 엔진 안에서 해결되고, 무엇보다 키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.

전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다. 벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다. Neo4j 도 같은 논리 — 고정 깊이 상속이면 ltree 로 충분하다.

전체 흐름

생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.

트리거 사이트 발행 — 즉시 크론 03:00 — 30일 경과 성과 저조 — 재생성 BullMQ 큐 60초 dedupe 창 재시도 3회 · 지수 백오프 동시성 2 생성 워커 OpenAI · gpt-4.1-mini Structured Outputs 임베딩 배치 1회 중복제거 4단계 해시 → trigram → 벡터 미일치만 신규 등록 나머지는 alias 흡수 PostgreSQL 16 pgvector · ltree · pg_trgm keyword (전역 사전) merchant_keyword qa_pair · generation_run Serving API GET /v1/sites/:id/seo GET /v1/sites/:id/aeo 읽기 99% · 캐시 대상 발행된 사이트 o2o-site-AEO 렌더링 시 호출 성과 수집 Search Console · 네이버 서치어드바이저 · 유입 로그 적재 job 후보 write 읽기 SEO / AEO payload 노출 · 클릭 CTR < 0.2% → 강등 기존 키워드 주입 — 중복 후보 생성 자체를 억제
점선 화살표가 이 설계의 핵심이다. 프롬프트에 해당 업종의 기존 키워드를 넣어 중복 후보가 만들어지기 전에 줄이고, 그래도 남는 것만 중복제거 단계가 처리한다. 생성 경로(위)와 서빙 경로(아래)는 Postgres 에서만 만나므로 OpenAI 가 느리거나 죽어도 발행된 사이트의 응답에는 영향이 없다.

중복제거 4단계

값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다.

LLM 후보 키워드 0 · 금칙어 필터 최고 · 1위 · 100% · 완치 1 · normalized 완전 일치 NFKC · 소문자 · 구두점/공백 제거 2 · pg_trgm 유사도 ≥ 0.6 표기 변형 · 오타 3 · 코사인 유사도 ≥ 0.92 의미 중복 — 후보 20건 안에서만 4 · 새 키워드로 INSERT embedding 저장 · usage_count 1 미일치 미일치 미일치 미일치 비용 0 B-tree 1회 GIN trgm HNSW top-20 INSERT 차단 — 저장하지 않음 rejected_banned 기존 키워드에 alias 흡수 강남 뿌리 염색 → 강남 뿌리염색 기존 키워드에 alias 흡수 강남 뿌리염색약 → 강남 뿌리염색 (0.67) 기존 키워드에 alias 흡수 강남 헤어샵 → 강남 미용실 (0.94) 어느 경로든 업체에는 연결된다 merchant_keyword · relevance · status
1~3 단계에서 걸린 표기는 버리지 않고 기존 키워드의 aliases[] 에 흡수한다. 롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이 강남 뿌리염색약 으로 성과를 보고해도 같은 키워드에 매칭된다.

실제 로컬 실행 결과

같은 지역·업종 업체를 순서대로 발행했을 때 npm run smoke 출력이다.

1. 레브살롱 (첫 업체)      후보 19 → 신규 19 / 중복 0
2. 헤어랩 강남점            후보 19 → 신규 4  / 중복(정확 15, 표기 0, 의미 0)
3. 강남 뷰티랩              후보 16 → 신규 3  / 중복(정확 12, 표기 1, 의미 0)

   matched_exact    강남 뿌리 염색      (sim=1.000 → '강남 뿌리염색')
   matched_trigram  강남 뿌리염색약     (sim=0.667 → '강남 뿌리염색')
   matched_exact    강남미용실추천      (sim=1.000 → '강남 미용실 추천')

참고 위 수치는 LLM_PROVIDER=mock 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다. 의미 중복(강남 미용실강남 헤어샵)은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.

데이터 모델

키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다.

industry path ltree · beauty.hair region path ltree · kr.seoul.gangnam merchant external_id ← 사이트 ID description · profile jsonb last_generated_at merchant_keyword relevance · status · source impressions · clicks · ctr PK (merchant_id, keyword_id) keyword — 전역 사전 canonical · 표시용 normalized UNIQUE · 판정용 aliases text[] · 흡수된 표기 embedding vector(1536) HNSW intent · locale usage_count qa_pair question · answer normalized_question UNIQUE embedding vector(1536) 업종 분류 지역 분류 1 : N N : 1 1 : N
강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다. 업체별 관련도·성과는 전부 merchant_keyword 가 들고 있으므로 사전을 오염시키지 않고 업체마다 다른 순위를 낼 수 있다.

API

:id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다. 연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다.

메서드경로용도
GET/health헬스체크 · 현재 LLM provider 확인
POST/v1/merchants/publish사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재. sync:true 면 동기 실행
POST/v1/merchants/:id/generate수동 재생성. ?sync=true 로 결과를 즉시 확인
GET/v1/sites/:id/seo발행 사이트가 렌더링 시 호출. title · description · keywords · tags(alias 포함)
GET/v1/sites/:id/aeo답변엔진용 topics · FAQ · structuredDataHints
POST/v1/keywords/search어드민 — 자연어 질의로 키워드 사전 벡터 검색
POST/v1/sites/:id/performance노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등

SEO 응답

$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'

{
  "title": "레브살롱 | 강남 미용실",
  "description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
  "keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
  "tags": [
    { "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
      "aliases": ["강남미용실"] }
  ]
}

AEO 응답

SEO 가 키워드라면 AEO 는 질문-답변 쌍과 구조화 데이터다. AI 검색 크롤러가 인용하는 것은 이쪽이다. structuredDataHints 는 후속 단계에서 LocalBusiness / FAQPage JSON-LD 로 그대로 매핑되도록 필드를 미리 맞춰 두었다.

{
  "topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
  "faqs": [
    { "question": "레브살롱은(는) 어디에 있나요?",
      "answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
  ],
  "structuredDataHints": {
    "type": "LocalBusiness", "name": "레브살롱",
    "areaServed": "강남", "category": "미용실"
  }
}

기술 선택

레이어선택이유
런타임NestJS · TypeScripto2o-site-AEO 와 payload 타입을 공유할 수 있다
DBPostgreSQL 16 + pgvector
+ ltree + pg_trgm
정확 · 의미 · 계층 조회 3-in-1
DB 접근postgres.js (raw SQL)벡터 연산자 <=>ltree 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다
큐 · 스케줄BullMQ + Redis60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장
LLMOpenAI Structured Outputs
text-embedding-3-small
JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다
관측generation_run 테이블프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다

로컬 실행

npm install
cp .env.example .env      # 기본 LLM_PROVIDER=mock — API 키 불필요
npm run db:up             # postgres(pgvector) + redis
npm run db:migrate && npm run db:seed
npm start                 # http://localhost:3100
npm run smoke             # 다른 터미널 — 엔드투엔드 점검

남은 작업

연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.

  • next JSON-LD 조립 — structuredDataHintsLocalBusiness / FAQPage / Service
  • next /llms.txt 서빙 — AI 검색 크롤러 진입점
  • later 업종 ltree 상위 노드 키워드 상속 (source: 'inherited')
  • later Redis 응답 캐시 — 서빙은 읽기 99%, TTL 1시간 + 발행 이벤트 무효화
  • later Search Console API 직접 연동 (지금은 /performance 수동 주입)
  • later 키워드 승인 · 차단 어드민 UI