# o2o-site-ontology o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스. - **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건) - 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색 - 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔** - 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만 ## 빠른 시작 (로컬) ```bash 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 로 전환 ```bash # .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 복원 (권장) ```bash 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. 재적재 ```bash 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.json` 의 `type`(해변·산간·호수·강변·도심·섬·계곡)에 따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 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[]` 로 흡수한다 (롱테일 검색어 보존 + 성과 피드백 매칭에 사용). 임계값은 `.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 둘 다 받는다. ### 발행 웹훅 예시 ```bash 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": ["주차가능"] } }' ``` ### 서빙 예시 ```bash curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8' ``` ```json { "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')` 만 본다. 사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다. #### 고객 언어 신호 리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다. ```json "hashtags": ["#군산감성숙소", "#뚜벅이여행"], "reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }] ``` 빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다. 현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다. #### 사실 기반 필터 — 벡터가 못 거르는 것 임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다. | 규칙 | 예시 | |---|---| | 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 | | 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 | | 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 | | **미확인 시설** | `바베큐` 가 `unverified` → 배제하지 않고 **보류** 표시 | 마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다. 스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다. #### 레인별 출력 = SEO 페이지 배분 응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 — 한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다. | 레인 | → 페이지 | 주력 | |---|---|---| | 유형 | 메인 | 군산 펜션 독채 | | 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 | | 동반자 | 객실 | 군산 커플 프라이빗 펜션 | | 시설 | 시설 | 군산 프라이빗 펜션 | ### 매칭 예시 ```bash 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` 로 바꿔 다시 생성하면 된다.