프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측: 통짜는 점수 폭 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>
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[] 로 흡수한다
(롱테일 검색어 보존 + 성과 피드백 매칭에 사용).
임계값은 .env 의 DEDUP_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·유입 로그 피드백 → 저성과 키워드 강등 |
:id 는 external_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 로 바꿔 다시 생성하면 된다.