o2o-site-ontology
업체 사이트를 발행하면 그 업체에 맞는 검색 키워드·태그·질문답변이 따라붙어야 한다. LLM 이 주기적으로 후보를 만들고, 4단계 중복제거가 전역 키워드 사전을 깨끗하게 유지하고, 발행된 사이트는 REST 로 완성된 payload 만 받아 쓴다.
둘 중 하나를 고르는 문제가 아니다. 이 서비스는 성격이 다른 세 종류의 조회를 동시에 요구한다.
| 조회 유형 | 실제 질의 | 필요한 것 |
|---|---|---|
| 정확 조회 | 업체 A 의 활성 키워드 20개 | B-tree / 관계형 조인 |
| 의미 조회 | 이 후보가 기존 키워드와 의미상 겹치는가 | vector (HNSW) |
| 관계 탐색 | 업종 트리 상위에서 물려받을 공통 키워드 | ltree 계층 / recursive CTE |
결론 — PostgreSQL 하나로 시작한다.
pgvector + ltree + pg_trgm + JSONB 로 세 가지가 모두 한 엔진 안에서 해결되고,
무엇보다 키워드 조회에는 항상 "어느 업체의"라는 조인이 따라붙는다.
전용 벡터 DB 를 지금 분리하면 매 요청이 2-hop 이 되고 정합성을 따로 관리해야 한다.
벡터 행이 1천만 건을 넘거나 ANN 지연이 실제로 문제가 되는 시점에 Qdrant 로 떼어내도 늦지 않다.
Neo4j 도 같은 논리 — 고정 깊이 상속이면 ltree 로 충분하다.
생성은 큐 뒤에서 비동기로, 서빙은 DB 읽기만으로. 두 경로가 만나는 지점은 Postgres 한 곳뿐이다.
값싼 판정을 먼저, 비싼 판정을 나중에. 벡터 비교는 후보 20건 안에서만 일어나므로 전수 비교가 발생하지 않는다.
aliases[] 에 흡수한다.
롱테일 검색어를 잃지 않으면서 사전은 한 행으로 유지되고, 나중에 Search Console 이
강남 뿌리염색약 으로 성과를 보고해도 같은 키워드에 매칭된다.
같은 지역·업종 업체를 순서대로 발행했을 때 npm run smoke 출력이다.
1. 레브살롱 (첫 업체) 후보 19 → 신규 19 / 중복 0 2. 헤어랩 강남점 후보 19 → 신규 4 / 중복(정확 15, 표기 0, 의미 0) 3. 강남 뷰티랩 후보 16 → 신규 3 / 중복(정확 12, 표기 1, 의미 0) matched_exact 강남 뿌리 염색 (sim=1.000 → '강남 뿌리염색') matched_trigram 강남 뿌리염색약 (sim=0.667 → '강남 뿌리염색') matched_exact 강남미용실추천 (sim=1.000 → '강남 미용실 추천')
참고
위 수치는 LLM_PROVIDER=mock 기준이다. mock 임베딩은 문자 bigram 해싱이라 표기 유사도만 잡는다.
의미 중복(강남 미용실 ↔ 강남 헤어샵)은 실제 text-embedding-3-small 로 전환해야 3단계가 발동한다.
키워드를 업체에 복제하지 않는 것이 이 스키마의 전부다. 복제하는 순간 중복제거 자체가 성립하지 않는다.
강남 미용실 을 100개 업체가 쓰더라도 keyword 에는 행이 하나, 임베딩도 하나뿐이다.
업체별 관련도·성과는 전부 merchant_keyword 가 들고 있으므로 사전을 오염시키지 않고
업체마다 다른 순위를 낼 수 있다.
:id 는 o2o-site-AEO 의 external_id 와 내부 UUID 를 모두 받는다.
연동 쪽에서 ID 매핑 테이블을 따로 들 필요가 없다.
| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | /health | 헬스체크 · 현재 LLM provider 확인 |
| POST | /v1/merchants/publish | 사이트 발행 웹훅. 업체 upsert 후 생성 작업 적재. sync:true 면 동기 실행 |
| POST | /v1/merchants/:id/generate | 수동 재생성. ?sync=true 로 결과를 즉시 확인 |
| GET | /v1/sites/:id/seo | 발행 사이트가 렌더링 시 호출. title · description · keywords · tags(alias 포함) |
| GET | /v1/sites/:id/aeo | 답변엔진용 topics · FAQ · structuredDataHints |
| POST | /v1/keywords/search | 어드민 — 자연어 질의로 키워드 사전 벡터 검색 |
| POST | /v1/sites/:id/performance | 노출·클릭 주입 → CTR 갱신 → 저성과 키워드 강등 |
$ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
{
"title": "레브살롱 | 강남 미용실",
"description": "강남역 3번 출구 앞 프라이빗 헤어살롱. … 정보를 확인하세요.",
"keywords": ["레브살롱", "강남 미용실", "강남 남자 커트", "강남 두피 클리닉", …],
"tags": [
{ "keyword": "강남 미용실", "intent": "local", "relevance": 0.95,
"aliases": ["강남미용실"] }
]
}
SEO 가 키워드라면 AEO 는 질문-답변 쌍과 구조화 데이터다. AI 검색 크롤러가 인용하는 것은 이쪽이다.
structuredDataHints 는 후속 단계에서 LocalBusiness / FAQPage JSON-LD 로 그대로 매핑되도록
필드를 미리 맞춰 두었다.
{
"topics": ["강남 미용실", "강남 남자 커트", "강남 여성 펌"],
"faqs": [
{ "question": "레브살롱은(는) 어디에 있나요?",
"answer": "레브살롱은(는) 강남에 위치한 미용실입니다." }
],
"structuredDataHints": {
"type": "LocalBusiness", "name": "레브살롱",
"areaServed": "강남", "category": "미용실"
}
}
| 레이어 | 선택 | 이유 |
|---|---|---|
| 런타임 | NestJS · TypeScript | o2o-site-AEO 와 payload 타입을 공유할 수 있다 |
| DB | PostgreSQL 16 + pgvector + ltree + pg_trgm | 정확 · 의미 · 계층 조회 3-in-1 |
| DB 접근 | postgres.js (raw SQL) | 벡터 연산자 <=> 와 ltree 는 어차피 raw SQL. ORM 을 얹으면 우회 코드가 더 는다 |
| 큐 · 스케줄 | BullMQ + Redis | 60초 dedupe 창, 지수 백오프 재시도, 크론이 전부 내장 |
| LLM | OpenAI Structured Outputs text-embedding-3-small | JSON Schema 강제 — 자유 텍스트 파싱은 반드시 깨진다 |
| 관측 | generation_run 테이블 | 프롬프트 버전 · 토큰 · 단계별 통계를 행으로 남긴다 |
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 npm start # http://localhost:3100 npm run smoke # 다른 터미널 — 엔드투엔드 점검
연동에 필요한 API 표면은 이미 고정되어 있다. 아래는 그 뒤에서 채워 넣는 것들이다.
structuredDataHints → LocalBusiness / FAQPage / Service/llms.txt 서빙 — AI 검색 크롤러 진입점ltree 상위 노드 키워드 상속 (source: 'inherited')/performance 수동 주입)