발행된 사이트에 업체별 SEO/AEO 키워드를 제공하는 서비스. - PostgreSQL 16 + pgvector/ltree/pg_trgm 단일 스토어 (정확·의미·계층 조회를 한 엔진에서 처리) - 키워드는 전역 사전 + merchant_keyword 연결 테이블 구조 - 4단계 계단식 중복제거: 금칙어 → normalized 완전일치 → pg_trgm → 코사인 유사도, 걸린 표기는 aliases[] 로 흡수 - BullMQ 생성 큐 (발행 즉시 / 일 1회 크론 / 성과 기반) - OpenAI Structured Outputs + mock provider (API 키 없이 로컬 전 구간 동작) - 서빙 API: /v1/sites/:id/seo, /aeo, /performance, /keywords/search - docs/architecture.html 설계 도식 JSON-LD 조립과 o2o-site-AEO 연동은 후속 작업. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
5.7 KiB
SQL
125 lines
5.7 KiB
SQL
-- o2o-site-ontology : initial schema
|
|
-- pgvector(유사도) + ltree(업종/지역 계층) + pg_trgm(표기 변형) 3-in-1
|
|
|
|
CREATE EXTENSION IF NOT EXISTS vector;
|
|
CREATE EXTENSION IF NOT EXISTS ltree;
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
|
|
-- ---------------------------------------------------------------- 분류 계층
|
|
CREATE TABLE IF NOT EXISTS industry (
|
|
id text PRIMARY KEY,
|
|
path ltree NOT NULL UNIQUE,
|
|
name text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS industry_path_gist ON industry USING gist (path);
|
|
|
|
CREATE TABLE IF NOT EXISTS region (
|
|
id text PRIMARY KEY,
|
|
path ltree NOT NULL UNIQUE,
|
|
name text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS region_path_gist ON region USING gist (path);
|
|
|
|
-- ---------------------------------------------------------------- 업체
|
|
CREATE TABLE IF NOT EXISTS merchant (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
external_id text NOT NULL UNIQUE, -- o2o-site-AEO 의 사이트/업체 ID
|
|
name text NOT NULL,
|
|
industry_id text REFERENCES industry(id),
|
|
region_id text REFERENCES region(id),
|
|
description text NOT NULL DEFAULT '',
|
|
profile jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
site_url text,
|
|
last_generated_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS merchant_industry_idx ON merchant (industry_id);
|
|
CREATE INDEX IF NOT EXISTS merchant_stale_idx ON merchant (last_generated_at NULLS FIRST);
|
|
|
|
-- ---------------------------------------------------------------- 전역 키워드 사전
|
|
DO $$ BEGIN
|
|
CREATE TYPE keyword_intent AS ENUM
|
|
('informational','navigational','transactional','local','brand');
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
CREATE TABLE IF NOT EXISTS keyword (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
canonical text NOT NULL, -- 화면 노출용 대표 표기
|
|
normalized text NOT NULL, -- 중복 판정용 정규화 표기
|
|
locale text NOT NULL DEFAULT 'ko-KR',
|
|
aliases text[] NOT NULL DEFAULT '{}', -- 흡수된 표기 변형 (롱테일 확보)
|
|
intent keyword_intent NOT NULL DEFAULT 'informational',
|
|
industry_id text REFERENCES industry(id),
|
|
region_id text REFERENCES region(id),
|
|
embedding vector(1536),
|
|
usage_count integer NOT NULL DEFAULT 0, -- 몇 개 업체가 쓰는가
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT keyword_normalized_locale_uq UNIQUE (normalized, locale)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS keyword_embedding_hnsw
|
|
ON keyword USING hnsw (embedding vector_cosine_ops);
|
|
CREATE INDEX IF NOT EXISTS keyword_normalized_trgm
|
|
ON keyword USING gin (normalized gin_trgm_ops);
|
|
CREATE INDEX IF NOT EXISTS keyword_industry_idx ON keyword (industry_id);
|
|
|
|
-- ---------------------------------------------------------------- 업체 <-> 키워드
|
|
DO $$ BEGIN
|
|
CREATE TYPE merchant_keyword_status AS ENUM
|
|
('candidate','active','demoted','blocked');
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
CREATE TABLE IF NOT EXISTS merchant_keyword (
|
|
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
|
|
keyword_id uuid NOT NULL REFERENCES keyword(id) ON DELETE CASCADE,
|
|
relevance real NOT NULL DEFAULT 0,
|
|
source text NOT NULL DEFAULT 'llm', -- llm | manual | inherited
|
|
status merchant_keyword_status NOT NULL DEFAULT 'candidate',
|
|
rationale text,
|
|
impressions bigint NOT NULL DEFAULT 0,
|
|
clicks bigint NOT NULL DEFAULT 0,
|
|
ctr real NOT NULL DEFAULT 0,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (merchant_id, keyword_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS merchant_keyword_serving_idx
|
|
ON merchant_keyword (merchant_id, status, relevance DESC);
|
|
|
|
-- ---------------------------------------------------------------- AEO 질문-답변
|
|
CREATE TABLE IF NOT EXISTS qa_pair (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
merchant_id uuid NOT NULL REFERENCES merchant(id) ON DELETE CASCADE,
|
|
question text NOT NULL,
|
|
answer text NOT NULL,
|
|
normalized_question text NOT NULL,
|
|
embedding vector(1536),
|
|
status merchant_keyword_status NOT NULL DEFAULT 'active',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT qa_pair_merchant_question_uq UNIQUE (merchant_id, normalized_question)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS qa_pair_merchant_idx ON qa_pair (merchant_id, status);
|
|
|
|
-- ---------------------------------------------------------------- 생성 감사 로그
|
|
CREATE TABLE IF NOT EXISTS generation_run (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
merchant_id uuid REFERENCES merchant(id) ON DELETE CASCADE,
|
|
provider text NOT NULL,
|
|
model text NOT NULL,
|
|
prompt_version text NOT NULL,
|
|
trigger text NOT NULL, -- published | scheduled | manual
|
|
status text NOT NULL DEFAULT 'running', -- running | succeeded | failed
|
|
input jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
output jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
stats jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
error text,
|
|
started_at timestamptz NOT NULL DEFAULT now(),
|
|
finished_at timestamptz
|
|
);
|
|
CREATE INDEX IF NOT EXISTS generation_run_merchant_idx
|
|
ON generation_run (merchant_id, started_at DESC);
|