o2o-site-ontology/README.md
hbyang edde23f15e 고정 데이터셋 1,000건 적재 + 로컬 임베딩 + 매칭 콘솔
정책 변경: 주기 수집 없이 고정 데이터셋을 1회 적재한다.

- data/gunsan-pension-keywords.json — "군산 펜션" 키워드·태그 1,000건
  실제 군산 지명·관광지·숙박 시설 어휘 × 로컬 검색 패턴으로 작성
- 임베딩을 LlmProvider 에서 EmbeddingProvider 로 분리
  (mock | local:multilingual-e5-small | openai), e5 의 query/passage 비대칭 반영
- vector(1536) → vector(384) 마이그레이션, keyword 에 source/kind/category 추가
- scripts/ingest-dataset.ts — 어휘 중복만 자동 병합, 벡터 근접쌍은 검토 목록만 출력
- POST /v1/match — 업체명(띄어쓰기 무관) 또는 문장 → 사전에서 매칭
  업체는 프로필 전체를 질의문으로 조립해 임베딩
- GET /demo — 매칭 콘솔 (public/demo.html)

실측으로 코사인 자동 병합 임계값을 0.92 → 0.99 로 정정.
짧은 한글 키워드는 같은 도메인이면 0.93+ 가 기본이라 0.92 는 오병합을 부른다.

적재 결과: 1,000건 → 어휘 중복 27 병합, 금칙어 2 차단 → 971건.

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

10 KiB

o2o-site-ontology

o2o-site-AEO 가 발행한 사이트에 업체별 SEO/AEO 키워드를 제공하는 온톨로지 서비스.

  • 고정 데이터셋 1회 적재 정책 — 주기 수집 없음 (data/gunsan-pension-keywords.json, 1,000건)
  • 로컬 임베딩(multilingual-e5-small, 384차원)으로 pgvector 에 적재 후 의미 검색
  • 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 매칭 API + 데모 콘솔
  • 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만

빠른 시작 (로컬)

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               # 업종/지역 계층 + 데모 업체 3곳
npm run dataset:build         # 1,000건 데이터셋 생성 → data/
npm run dataset:ingest        # 임베딩 + pgvector 적재 (최초 1회 모델 다운로드)
npm start                     # http://localhost:3100

브라우저에서 http://localhost:3100/demo 를 열면 매칭 콘솔이 뜬다. 입력창에 스테이 머뭄 을 넣으면 (띄어쓰기가 달라도) 업체를 해석하고 적재된 971건 사전에서 잘 맞는 키워드를 순위대로 보여준다.

npm run db:reset 은 볼륨까지 지우고 migrate + seed 를 다시 돌린다.

실제 OpenAI 로 전환

# .env
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_EMBEDDING_MODEL=text-embedding-3-small

LLM_PROVIDER=mock 은 문자 bigram 해싱 임베딩을 쓴다. 랜덤이 아니라 비슷한 문자열이면 비슷한 벡터가 나오므로 중복제거 파이프라인 검증에는 충분하지만, 의미 기반 중복 (강남 미용실강남 헤어샵) 은 실제 임베딩 모델에서만 잡힌다.

데이터 모델

테이블 역할
industry / region ltree 업종·지역 계층. 상위 노드 키워드 상속의 기반
merchant 업체. external_id 가 o2o-site-AEO 의 사이트 ID
keyword 전역 키워드 사전. normalized 유니크, aliases[], embedding vector(1536)
merchant_keyword 업체 ↔ 키워드 연결. relevance / status / impressions / ctr
qa_pair AEO 용 질문-답변 쌍
generation_run 생성 감사 로그 (프롬프트 버전·토큰·통계)

키워드는 업체에 복제하지 않고 전역 사전 + 연결 테이블로 둔다. 그래야 임베딩이 하나만 저장되고, 강남 미용실 을 쓰는 업체가 100곳이어도 중복제거가 성립한다.

데이터셋

data/gunsan-pension-keywords.json — "군산 펜션" 주제로 직접 작성한 1,000건. 실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과 숙박 시설 용어를 어휘로 두고, 한국 로컬 숙박 검색에서 실제로 쓰이는 패턴만 전개했다.

카테고리 건수 예시
롱테일 374 군산 커플 오션뷰 펜션
시설 104 군산 자쿠지 펜션
권역 99 선유도 독채펜션
동반자 98 군산 애견동반 펜션
시즌 72 군산 여름휴가 펜션
관광지 64 경암동 철길마을 근처 숙소
태그 63 오션뷰 · 불멍 · 애견운동장
코어 51 군산 펜션 추천
질문형 33 군산 펜션 바베큐 가능한가요
의도 13 군산 펜션 실시간예약

적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → 971건 적재.

npm run dataset:build 로 다시 만들고 npm run dataset:ingest 로 다시 넣는다. 재적재는 ON CONFLICT (normalized, locale) DO UPDATE 라 몇 번을 돌려도 971건을 유지한다.

임베딩 임계값 — 실측으로 정정한 부분

설계 초안의 코사인 자동 병합 임계값 0.92 는 틀렸다. 짧은 한글 키워드에서는 같은 도메인이기만 하면 절대 코사인이 기본적으로 높게 나온다.

실제 관계 cos (e5-small)
군산 키즈룸 펜션 ↔ 군산 펜션 키즈룸 중복 (어순) 0.9995
군산 애견동반 펜션 ↔ 군산 반려견 동반 펜션 중복 (동의어) 0.9886
선유도 펜션 ↔ 선유도 팬션 중복 (오타) 0.9585
군산 펜션 ↔ 군산 호텔 별개 0.9698
선유도 펜션 ↔ 새만금 펜션 별개 0.9356

중복과 별개의 분포가 겹치므로 단일 임계값으로는 깨끗하게 못 가른다 (paraphrase-multilingual-MiniLM-L12-v2 도 동일).

그래서 정책을 이렇게 바꿨다.

  • 자동 병합의 주력은 어휘 단계(1~2) — 공백/구두점 정규화와 pg_trgm 이 오타·표기 변형을 잡는다
  • 벡터 단계는 0.99 로 올려 잡는다 — 어순 변형처럼 확실한 것만 걸린다
  • 적재 시에는 벡터 병합을 아예 하지 않고 검토 목록만 출력한다 (npm run dataset:ingest 끝부분)

중복제거 4단계

값비싼 벡터 비교를 마지막에 두고, 후보 집합 안에서만 수행한다.

단계 방법 걸러내는 것
0 금칙어 필터 최고, 1위, 100% 등 과장광고
1 normalized 완전 일치 (공백·구두점 제거) 강남 뿌리 염색 = 강남 뿌리염색
2 pg_trgm 유사도 ≥ 0.6 강남 뿌리염색약강남 뿌리염색
3 코사인 유사도 ≥ 0.92 강남 미용실강남 헤어샵 (의미 중복)
4 신규 등록 위에 안 걸리면 새 키워드

1~3 단계에서 매칭되면 원래 표기는 버리지 않고 기존 키워드의 aliases[] 로 흡수한다 (롱테일 검색어 보존 + 성과 피드백 매칭에 사용).

임계값은 .envDEDUP_COSINE_THRESHOLD / DEDUP_TRIGRAM_THRESHOLD 로 조정.

API

메서드 경로 용도
GET /health 헬스체크
POST /v1/merchants/publish 사이트 발행 웹훅 — 업체 upsert + 생성 예약 (sync:true 면 동기 실행)
POST /v1/merchants/:id/generate?sync=true 수동 재생성
GET /v1/merchants /v1/merchants/:id 조회
GET /v1/sites/:id/seo?limit=20 발행 사이트가 렌더링 시 호출 — title/description/keywords/tags
GET /v1/sites/:id/aeo?limit=10 답변엔진용 topics/FAQ/structuredDataHints
POST /v1/match 업체명 또는 문장 → 사전에서 잘 맞는 키워드 (데모 콘솔이 쓰는 API)
POST /v1/keywords/search 의미 기반 키워드 검색 (어드민)
GET /demo 매칭 콘솔 (로컬 확인용)
POST /v1/sites/:id/performance Search Console·유입 로그 피드백 → 저성과 키워드 강등

:idexternal_id 또는 내부 UUID 둘 다 받는다.

발행 웹훅 예시

curl -X POST http://localhost:3100/v1/merchants/publish \
  -H 'content-type: application/json' \
  -d '{
    "externalId": "site-1003",
    "name": "강남 뷰티랩",
    "industryId": "beauty.hair",
    "regionId": "kr.seoul.gangnam",
    "description": "강남 미용실. 염색 전문.",
    "profile": { "services": ["뿌리염색", "여성펌"], "features": ["주차가능"] }
  }'

서빙 예시

curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
{
  "title": "레브살롱 | 강남 미용실",
  "description": "강남역 3번 출구 앞 프라이빗 헤어살롱. ... 정보를 확인하세요.",
  "keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "..."],
  "tags": [{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95, "aliases": ["강남미용실"] }]
}

매칭 예시

curl -s -X POST http://localhost:3100/v1/match \
  -H 'content-type: application/json' -d '{"query":"스테이 머뭄","limit":5}'

업체명이면 상호만으로 임베딩하지 않고 프로필 전체를 질의문으로 조립한다. 상호는 브랜드명이라 그것만으로는 매칭이 얕아지기 때문이다.

해석: 스테이머뭄 (군산 / 펜션)     ← "스테이 머뭄" 과 띄어쓰기가 달라도 해석됨
질의문: 스테이머뭄 군산 펜션 고군산군도 초입에 자리한 독채 펜션 … 오션뷰 애견동반 …

0.8765  군산 독채펜션 예약        [transactional] 코어
0.8762  군산 애견동반 독채펜션     [local]         동반자
0.8751  고군산군도 독채펜션        [local]         권역
0.8735  군산 바베큐 펜션 예약      [transactional] 시설
0.8718  군산 오션뷰 펜션 예약      [transactional] 시설

업체가 해석되지 않으면 입력 문장을 그대로 질의로 쓴다.

"선유도 근처에서 바베큐 되는 독채"
  0.9166  선유도 독채펜션
  0.9155  선유도 바베큐 펜션
  0.9051  선유도해수욕장 근처 숙소

생성 주기

  • 발행 즉시/v1/merchants/publish 가 BullMQ 에 적재 (60초 dedupe 창)
  • 주기 리프레시 — 매일 03:00 크론이 REFRESH_INTERVAL_DAYS(기본 30일) 지난 업체를 적재
  • 성과 기반 — 노출 100회 이상 & CTR < 0.2% 인 키워드는 demoted 로 강등, 다음 사이클에서 대체

프롬프트에는 해당 업체와 같은 업종의 기존 키워드 목록을 넣어 중복 후보 생성 자체를 줄인다. 그래도 남는 중복만 위 4단계가 처리한다.

남은 작업

  • JSON-LD (LocalBusiness / FAQPage / Service) 조립 — structuredDataHints 를 그대로 매핑
  • /llms.txt 서빙
  • 업종 ltree 상위 노드 키워드 상속 (source: 'inherited')
  • Redis 응답 캐시 (서빙은 읽기 99%)
  • Search Console API 연동 (현재는 /performance 수동 주입)
  • 어드민 UI

아키텍처 도식

파일 용도
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 로 바꿔 다시 생성하면 된다.