o2o-site-ontology/README.md
hbyang eda8f09bae 팀원 로컬 세팅용 setup 스크립트
클론 후 npm run setup 한 줄로 끝나게 했다.
docker/node 버전 사전 점검 → .env 생성 → 컨테이너 → 마이그레이션 →
시드 → 키워드 7,093건 적재까지.

- scripts/setup.sh
- scripts/requirements.txt — 엑셀 스크립트용 openpyxl (누락돼 있었다)
- README 빠른 시작을 클론부터 시작하도록 수정, 포트/모델 다운로드 안내 추가

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

20 KiB
Raw Blame History

o2o-site-ontology

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

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

빠른 시작 (로컬)

필요한 것: Docker Desktop 실행 중 · Node 20+

git clone https://gitea.o2o.kr/Web4ai/o2o-site-ontology.git
cd o2o-site-ontology
npm install
npm run setup      # .env 생성 → 컨테이너 → 마이그레이션 → 시드 → 키워드 7,093건 적재
npm start          # http://localhost:3100

npm run setup 이 전부 한다. API 키는 필요 없다 (LLM=mock, 임베딩=로컬 모델). 최초 1회 임베딩 모델을 내려받는다 — 약 120MB, 1~2분. 그 뒤로는 오프라인으로 동작한다.

포트는 기존 개발환경과 겹치지 않게 잡아 두었다 — postgres 55432, redis 56379, 앱 3100.

확인: http://localhost:3100/demo 입력창에 스테이 머뭄

엑셀 산출 스크립트를 쓸 때만 파이썬 의존성이 필요하다.

pip3 install -r scripts/requirements.txt
수동으로 단계별 실행
cp .env.example .env
npm run db:up                    # postgres(pgvector) + redis
npm run db:migrate
npm run db:seed                  # 업종/지역 계층 + 데모 업체
npm run dataset:ingest           # 군산 상세 974건
npm run dataset:ingest-nationwide # 전국 54개 지역

브라우저에서 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동)으로 고치자 군산 원도심 독채펜션 / 군산 독채스테이 로 바뀌었다.

배포

DB 가 기준이다. 데이터셋 JSON 은 생성 원본일 뿐 적재분과 완전히 같지 않다 (지역 간 중복 태그가 한 행으로 합쳐지므로 7,129 → 6,243).

산출물 명령 용도
data/배포용_키워드_DB덤프.xlsx npm run db:export-xlsx DB 7개 테이블 전부. 8시트
data/ontology-dump.sql.gz npm run db:dump 임베딩 포함 그대로 복원. 13MB, git 제외

A. pg_dump 복원 (권장)

npm run db:dump
gunzip -c data/ontology-dump.sql.gz | psql "$TARGET_DATABASE_URL"

대상 DB 에 vector · ltree · pg_trgm 확장이 있어야 한다. 재임베딩이 없어 즉시 뜬다. 복원 검증 완료 — 6개 테이블 행수 일치, 임베딩 7,093/7,093 보존, HNSW 인덱스 재생성, 벡터 검색 동작.

B. 재적재

npm run db:migrate && npm run db:seed
npm run dataset:ingest && npm run dataset:ingest-nationwide

텍스트에서 임베딩을 다시 만든다. 최초 1회 모델 다운로드(약 50초) + 임베딩 약 15초. 같은 모델이면 값이 동일하게 나오므로 A 와 결과가 같다.

엑셀 시트 (DB 테이블과 1:1)

시트 테이블
키워드 keyword 7,093
지역 region 70
업종 industry 9
업체 merchant 4
업체키워드 merchant_keyword 11
QA(AEO) qa_pair 20
생성이력 generation_run 4
배포가이드 25

임베딩만 담지 않는다 (384 float × 7천 행). [임베딩] 열에 보유 여부만 표시하며, 같은 모델로 재생성하면 동일하게 복원된다.

현재 적재 내용

출처 건수 내용
nationwide 6,243 전국 54개 지역
dataset 850 군산 상세 (매칭 엔진 개발용)
합계 7,093 전부 임베딩 보유

⚠ 검색량은 아직 비어 있다. 실서비스 전에 키워드도구로 채우고 월 10 미만을 걷어내야 한다.

전국 지역별 데이터셋 (기획 변경분)

data/전국_펜션_SEO_AEO_키워드.xlsx — 전국 54개 펜션 수요 지역 × 7,138건. npm run dataset:nationwide 로 재생성한다 (data/regions.json → JSON → 엑셀).

시트 4개: 키워드 / 지역마스터 / 지역별요약 / 사용가이드

조합 폭발을 하지 않았다. 군산 단일 지역 974건을 54개에 곱하면 5만 건이 되는데, 단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다. 지역당 ~110건으로 눌렀다.

지역 성격이 시설 키워드를 결정한다. regions.jsontype(해변·산간·호수·강변·도심·섬·계곡)에 따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 0건, 태안·거제에는 산뷰가 0건이다.

티어 — 주력 568 / 보조 3,816 / 롱테일 1,836 / 태그 918. 주력은 페이지당 1개만 쓰는 대표 키워드 후보다.

이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다. 엑셀의 월간검색수·경쟁도 열은 비워 두었다. 네이버 검색광고 키워드도구로 채운 뒤 월 10 미만을 걷어내야 실제로 쓸 수 있다.

데이터셋 (군산 단일 지역 · 매칭 엔진용)

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 로 다시 넣는다. 적재는 upsert 이고, 데이터셋에서 빠진 행은 같이 지운다 — 안 그러면 재빌드할 때마다 이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다).

명령 용도
npm run dataset:build 데이터셋 생성
npm run dataset:ingest 임베딩 + 적재 + 잔여 정리
npm run dataset:purge 큐레이션 외 출처(llm 등) 제거. --apply 로 실행
npm run dataset:import-related 검색광고 키워드도구 내려받기(CSV/JSON) 병합. --apply 로 실행

dataset:import-related 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는 계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아 data/related-keywords.sample.csv 형식으로 두면 그대로 병합된다 — 나중에 API 를 붙여도 이 임포터를 재사용한다.

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

설계 초안의 코사인 자동 병합 임계값 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배로 벌어진다.

레인 구성

레인 가중치 출처 비고
유형 1.0 지역 + 업종 + 숙소유형 앵커. 주력 키워드가 여기서 나온다
고객언어 0.9 reviewSignals 빈출어 + hashtags 사업자 표현보다 검색어에 가깝다
위치 0.7 권역 + 행정동 + 인근 랜드마크
동반자 0.6 audiences
시설 0.6 정규화된 amenities

레인 텍스트는 낱말 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 신흥동신흥동 일본식가옥 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다.

후보 풀은 업체 업종으로 한정하고 source IN ('dataset','manual') 만 본다. 사전 전체를 뒤지면 다른 업종 키워드(강남 미용실 등)가 후보에 섞인다.

고객 언어 신호

리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도 집계만 받는다.

"hashtags": ["#군산감성숙소", "#뚜벅이여행"],
"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }]

빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다. 현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다.

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

임베딩은 "비슷함"만 알지 "최대 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 로 바꿔 다시 생성하면 된다.