발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면 `[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 — 화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다. - ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다) - ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이 musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다 - docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가. 자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다 - 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다 - 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다 검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료 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);
|