사전 만들기 아키텍처 개요

  1. 데이터 모델(단어장): term, 정의, 용례, 카테고리/태그, 동의어/별칭, 출처, 미디어 링크 등
  2. 임베딩: text-embedding-3-small (1536차원)으로 term+정의 텍스트를 벡터화
  3. DB: Postgres + pgvector (IVFFLAT/HNSW 인덱스)
  4. RAG 체인: LangChain retriever → 프롬프트 템플릿 → LLM(ChatOpenAI)

0) 설치 (Docker)

# docker-compose.yml
version: "3.8"
services:
  db:
    image: ankane/pgvector:latest   # Postgres + pgvector 포함 이미지
    environment:
      POSTGRES_USER: exuser
      POSTGRES_PASSWORD: expass
      POSTGRES_DB: exdict
    ports: ["5432:5432"]
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

실행:

docker compose up -d

1) SQL 스키마 (단어장 + 벡터)

임베딩 모델 text-embedding-3-small 기준 1536차원.

-- 확장 설치
CREATE EXTENSION IF NOT EXISTS vector;

-- 기본 테이블: 용어 사전
CREATE TABLE IF NOT EXISTS terms (
  id           BIGSERIAL PRIMARY KEY,
  term         TEXT NOT NULL,                           -- 표제어
  pos          TEXT,                                    -- 품사 (noun/verb 등)
  domain       TEXT,                                    -- 영상 도메인(촬영, 조명, 편집, VFX 등)
  synonyms     TEXT[],                                  -- 동의어/별칭
  definition   TEXT NOT NULL,                           -- 정의
  examples     TEXT[],                                  -- 용례
  tags         TEXT[],                                  -- 태그 (예: "color grading","HDR")
  source       TEXT,                                    -- 출처/레퍼런스(URL 등)
  media_urls   TEXT[],                                  -- 참고 이미지/영상 링크
  lang         TEXT DEFAULT 'ko',                       -- 언어(ko/en 등)
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now()
);

-- 검색용 결합 텍스트(표제어+정의+태그 등)를 벡터화하여 저장
ALTER TABLE terms
  ADD COLUMN IF NOT EXISTS embedding vector(1536);

-- 빠른 텍스트 검색용(선택) pg_trgm
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_terms_term_trgm ON terms USING gin (term gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_terms_definition_trgm ON terms USING gin (definition gin_trgm_ops);

-- 벡터 근접검색 인덱스 (IVFFLAT; 100 lists는 데이터 양에 맞춰 조정)
CREATE INDEX IF NOT EXISTS idx_terms_embedding_ivfflat
  ON terms USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

-- 업데이트 트리거(업데이트일시 자동)
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_terms_updated_at ON terms;
CREATE TRIGGER trg_terms_updated_at BEFORE UPDATE ON terms
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

메모

  • hybrid 검색(텍스트 BM25/트리그램 + 벡터)을 원하면 pg_trgm 인덱스가 도움이 됨.
  • HNSW 인덱스는 Postgres 버전/pgvector 빌드 옵션에 따라 사용 가능. 개발 초반엔 IVFFLAT로 충분.

2) 임베딩 & 적재(ingest)

CSV/JSON로 만든 단어장을 넣는 스크립트. (LangChain 없이도 가능하지만, 아래는 LangChain+psycopg2 조합 예시)

# ingest_terms.py
# pip install langchain-openai psycopg2-binary python-dotenv
import os, csv, json
import psycopg2
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings

load_dotenv()

PG_DSN = os.getenv("PG_DSN", "postgresql://exuser:expass@localhost:5432/exdict")
emb = OpenAIEmbeddings(model="text-embedding-3-small")

def upsert_term(cur, row, vector):
    cur.execute("""
        INSERT INTO terms (term, pos, domain, synonyms, definition, examples, tags, source, media_urls, lang, embedding)
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
        ON CONFLICT (term) DO UPDATE
        SET pos=EXCLUDED.pos,
            domain=EXCLUDED.domain,
            synonyms=EXCLUDED.synonyms,
            definition=EXCLUDED.definition,
            examples=EXCLUDED.examples,
            tags=EXCLUDED.tags,
            source=EXCLUDED.source,
            media_urls=EXCLUDED.media_urls,
            lang=EXCLUDED.lang,
            embedding=EXCLUDED.embedding,
            updated_at=now();
    """, (
        row["term"],
        row.get("pos"),
        row.get("domain"),
        row.get("synonyms"),
        row["definition"],
        row.get("examples"),
        row.get("tags"),
        row.get("source"),
        row.get("media_urls"),
        row.get("lang", "ko"),
        vector,
    ))

def build_embed_text(row):
    # 표제어 + 정의 + 태그/동의어를 하나로 이어 붙여 임베딩 품질 향상
    parts = [
        row["term"],
        row["definition"],
        ", ".join(row.get("synonyms", []) or []),
        ", ".join(row.get("tags", []) or []),
        row.get("domain", "") or ""
    ]
    return "\n".join([p for p in parts if p])

def load_rows_from_json(path):
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    # data 예시: [{term, definition, pos, domain, synonyms[], examples[], tags[], source, media_urls[], lang}]
    return data

if __name__ == "__main__":
    rows = load_rows_from_json("./terms.json")

    conn = psycopg2.connect(PG_DSN)
    conn.autocommit = False
    cur = conn.cursor()

    for row in rows:
        embed_text = build_embed_text(row)
        vec = emb.embed_query(embed_text)  # list[float] (1536)
        upsert_term(cur, row, vec)

    conn.commit()
    cur.close()
    conn.close()

    print(f"Ingested {len(rows)} terms.")

terms.json 예시:

[
  {
    "term": "color grading",
    "definition": "촬영된 영상의 색과 대비를 조정해 일관된 룩을 만드는 과정.",
    "pos": "noun",
    "domain": "post-production",
    "synonyms": ["grading", "color correction"],
    "examples": ["HDR 마스터링 전 그레이딩을 완료한다."],
    "tags": ["HDR", "DaVinci Resolve", "look"],
    "source": "EX Studio Guide",
    "media_urls": [],
    "lang": "ko"
  }
]

3) LangChain에서 pgvector 리트리버 구성

LangChain에는 PGVector 벡터스토어가 있어 연결이 간단해.

# rag_terms.py
# pip install langchain langchain-openai langchain-community python-dotenv psycopg2-binary
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores.pgvector import PGVector
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

load_dotenv()

PG_DSN = os.getenv("PG_DSN", "postgresql://exuser:expass@localhost:5432/exdict")

emb = OpenAIEmbeddings(model="text-embedding-3-small")
store = PGVector(
    connection_string=PG_DSN,
    embedding_function=emb,
    collection_name="terms_collection",   # 논리적 이름
    use_jsonb=True                        # 메타데이터 jsonb 저장
)

# 기존 테이블을 직접 썼다면, 아래처럼 새로 add가 필요 없을 수 있음.
# 단, LangChain PGVector는 자체 메타 테이블 구조를 쓰니,
# 운영에서는 "LangChain이 관리하는 컬렉션"으로 넣는 편이 편함.
# 이미 terms 테이블에 embedding을 저장했다면, 커스텀 retriever를 만들 수도 있음.

retriever = store.as_retriever(search_kwargs={"k": 5})

prompt = ChatPromptTemplate.from_messages([
    ("system",
     "너는 영상 제작/후반작업 전문가 사전의 큐레이터다. "
     "아래 컨텍스트를 근거로 정확하고 간결하게 답하고, "
     "컨텍스트에 없으면 모른다고 말해. 필요한 경우 용어 정의와 실무 팁을 함께 제시해."),
    ("human", "질문: {question}\n\n컨텍스트:\n{context}")
])

def format_docs(docs):
    out = []
    for i, d in enumerate(docs, 1):
        meta = d.metadata or {}
        term = meta.get("term") or ""
        domain = meta.get("domain") or ""
        out.append(f"[{i}] {term} ({domain})\n{d.page_content}")
    return "\n\n".join(out)

model = ChatOpenAI(model="gpt-4o-mini")
chain = (
    {
        "question": RunnablePassthrough(),
        "context": retriever | (lambda docs: format_docs(docs)),
    }
    | prompt
    | model
    | StrOutputParser()
)

print(chain.invoke("HDR 그레이딩할 때 PQ와 HLG 차이를 개념적으로 설명해줘."))

메모

  • 위 예시는 LangChain이 관리하는 PGVector 컬렉션을 쓰는 구조야.
  • 이미 우리가 만든 terms 테이블을 써서 커스텀 리트리버(직접 SQL로 k-NN + 조건 필터)를 만들 수도 있어. 운영에서는 이쪽이 더 투명함.

4) (선택) 커스텀 SQL 리트리버 예시

pgvector는 SQL 한 줄로 코사인 유사도 기반 k-NN이 가능해.

-- 질의 임베딩 : $1 (vector)
-- 태그/도메인 필터링도 WHERE 절로 가능
SELECT id, term, definition, domain, tags,
       1 - (embedding <=> $1) AS score   -- cosine similarity
FROM terms
WHERE (domain = $2 OR $2 IS NULL)
ORDER BY embedding <=> $1
LIMIT 5;

파이썬에서:

import psycopg2
from langchain_openai import OpenAIEmbeddings

emb = OpenAIEmbeddings(model="text-embedding-3-small")
qvec = emb.embed_query("HLG와 PQ의 톤매핑 차이")

cur.execute("""
    SELECT id, term, definition, domain, tags, 1 - (embedding <=> %s) AS score
    FROM terms
    ORDER BY embedding <=> %s
    LIMIT 5;
""", (qvec, qvec))
rows = cur.fetchall()

이렇게 가져온 결과를 LangChain 체인의 context로 넣으면 완전 커스텀 RAG가 돼.


5) 하이브리드 검색(추천)

  • 사용자가 정확한 용어로 찾을 땐 텍스트 매칭(BM25/pg_trgm) 성능이 더 좋을 때가 있음.
  • 따라서 (A) trigram LIKE/랭킹 + (B) 벡터 근접을 각 5개씩 뽑아 집합을 합쳐 재랭킹(간단히 점수 합산/정규화)하면 품질이 상승.

아이디어:

  1. term ILIKE '%hlg%' OR definition ILIKE '%hlg%' 상위 N
  2. 벡터 k-NN 상위 N
  3. 두 결과 합집합을 간단히 스코어 재조정 → 상위 K 제공

6) 데이터 설계 팁

  • 표제어와 정의는 최대한 짧고 명확하게. 길면 예시/배경은 examples/notes 필드로 분리.
  • 동의어/영문 표기(예: “룩업 테이블” ↔ “LUT”)를 적극 저장해서 검색 recall 확보.
  • 도메인 필드(촬영/조명/색보정/편집/VFX/사운드 등)로 필터링 가능하게.
  • 언어 분리(ko/en)로 다국어 확장 대비.
  • 메타데이터(출처, 표준 규격 링크) 넣으면 신뢰도↑.

7) 무엇부터 하면 좋나 (실행 순서)

  1. docker-compose로 Postgres+pgvector 기동
  2. SQL 스키마 생성
  3. 손에 쥔 용어장 초안(CSV/JSON) 만들기 → ingest_terms.py로 적재
  4. 커스텀 SQL 리트리버 또는 LangChain PGVector 중 하나 선택해 RAG 체인 연결
  5. 하이브리드 검색/도메인 필터/출처 표시 등 UX 개선

원하면:

  • Chroma로 시작하는 초간단 버전
  • pgvector 커스텀 리트리버 전체 코드(SQL+파이썬)
  • Notion/Markdown/PDF 등 원천을 자동 크롤링해 용어장으로 변환하는 로더 스크립트
    도 바로 만들어줄게.
    먼저 어떤 경로(Chroma vs pgvector)로 갈지 말만 해줘.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다