[카테고리:] 미분류

  • 사전 만들기 아키텍처 개요

    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)로 갈지 말만 해줘.