o2o-site-ontology/README.md
hbyang f7485aab32 매칭을 속성별 다중 질의 + 가중 RRF + 사실 기반 필터로 재구성
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다.
실측: 통짜는 점수 폭 0.0076, 속성별로 쪼개면 0.0624 (8배).

- src/serving/match.rules.ts — 레인 빌더, 권역 판정, 수용 인원/시설 필터,
  시설 통제 어휘 정규화
- src/serving/match.service.ts — 레인별 검색 → 가중 RRF 융합 → 필터 →
  레인별 그룹(byLane) 출력. 근거(어느 레인 몇 위)를 함께 반환
- POST /v1/match 에 mode=fusion(기본) / single(기존 통짜, 비교용)
- 데모 페이지: 모드 토글, 레인 카드, 융합/레인별/배제됨 탭

레인 설계에서 실측으로 고친 것
- 브랜드 레인 제거 — 상호는 사전에 없어 generic '군산 펜션 ~예약'만 끌어왔다
- 권역/인근 레인 병합 — '신흥동' 토큰이 겹쳐 위치 키워드가 상위를 쓸어갔다
- RRF 상수 60 → 20 — 60은 1위/40위 기여도 차이가 1.6배뿐이라 generic이 유리했다

사실 기반 필터는 벡터가 못 거르는 모순을 배제한다.
스테이머뭄(최대 4인, 원도심) 기준 61건 배제.
미확인 시설은 배제하지 않고 '보류'로 표시한다 — 없음이 아니라 모름이므로.

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

14 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곳이어도 중복제거가 성립한다.

시드 데이터 주의

src/db/seed.ts 의 업체 중 스테이머뭄(site-3001)만 실재 업체이고, 나머지(레브살롱·헤어랩·소담한상)는 동작 확인용 가상 업체다. 스테이머뭄 프로필도 공개 정보로 확인된 항목만 채웠고, 가격·바베큐·스파·주차·애견동반은 profile.unverified 에 남겨 두었다 — 사업자 확인 후 채울 것.

매칭 품질은 프로필 정확도에 그대로 좌우된다. 실제로 초기 시드에 잘못 들어가 있던 "고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 오션뷰 / 고군산군도 로 나왔고, 실제 값(원도심 신흥동, 독채 2동)으로 고치자 군산 원도심 독채펜션 / 군산 독채스테이 로 바뀌었다.

데이터셋

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 업체명 또는 문장 → 사전에서 잘 맞는 키워드. mode=fusion(기본) / single(통짜, 비교용)
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": ["강남미용실"] }]
}

매칭 — 속성별 다중 질의 + 사실 기반 필터

프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측:

방식 점수 범위
통짜 질의문 하나 0.8761 ~ 0.8837 0.0076
속성별로 쪼갠 질의 0.8552 ~ 0.9176 0.0624

976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다. 그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다.

레인 가중치 질의문 예시
유형 1.0 군산 펜션 독채 감성숙소
위치 0.7 원도심 신흥동 말랭이마을 동국사 근처
동반자 0.6 커플 친구 가족 혼자
시설 0.6 프라이빗

레인 설계에서 실측으로 배운 것 세 가지.

  • 브랜드 레인을 두면 안 된다. 상호는 사전에 없으므로 결국 군산 펜션 만 남아 가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 ~예약 으로 도배됐다.
  • 레인끼리 겹치면 안 된다. 권역과 인근을 따로 두었더니 신흥동 토큰이 양쪽에 걸려 위치 키워드가 상위를 쓸어갔고, 정작 핵심인 군산 펜션 독채 가 8위로 밀렸다. 한 레인으로 합쳤다.
  • RRF 상수는 관례값 60 이 아니라 20. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.

사실 기반 필터 — 벡터가 못 거르는 것

임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.

규칙 예시
수용 인원 최대 4인 → 군산 단체 독채펜션, 군산 독채 세미나실 펜션 배제
권역 불일치 원도심 업체 → 선유도·오션뷰 계열 배제
미보유 시설 수영장 없음 → 군산 독채 온수풀 펜션 배제
미확인 시설 바베큐unverified → 배제하지 않고 보류 표시

마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다. 스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 excluded 로 함께 내려준다.

레인별 출력 = SEO 페이지 배분

응답의 byLane 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 — 한 페이지의 주력 키워드는 1개여야 하므로, 레인 1위가 그 페이지의 주력이 된다.

레인 → 페이지 주력
유형 메인 군산 펜션 독채
위치 주변 여행 신흥동 일본식가옥 근처 숙소
동반자 객실 군산 커플 프라이빗 펜션
시설 시설 군산 프라이빗 펜션

매칭 예시

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