-- 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);