- 데이터 모델(단어장): term, 정의, 용례, 카테고리/태그, 동의어/별칭, 출처, 미디어 링크 등
- 임베딩:
text-embedding-3-small
(1536차원)으로 term+정의 텍스트를 벡터화 - DB: Postgres + pgvector (IVFFLAT/HNSW 인덱스)
- 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개씩 뽑아 집합을 합쳐 재랭킹(간단히 점수 합산/정규화)하면 품질이 상승.
아이디어:
term ILIKE '%hlg%' OR definition ILIKE '%hlg%'
상위 N- 벡터 k-NN 상위 N
- 두 결과 합집합을 간단히 스코어 재조정 → 상위 K 제공
6) 데이터 설계 팁
- 표제어와 정의는 최대한 짧고 명확하게. 길면 예시/배경은
examples
/notes
필드로 분리. - 동의어/영문 표기(예: “룩업 테이블” ↔ “LUT”)를 적극 저장해서 검색 recall 확보.
- 도메인 필드(촬영/조명/색보정/편집/VFX/사운드 등)로 필터링 가능하게.
- 언어 분리(ko/en)로 다국어 확장 대비.
- 메타데이터(출처, 표준 규격 링크) 넣으면 신뢰도↑.
7) 무엇부터 하면 좋나 (실행 순서)
- docker-compose로 Postgres+pgvector 기동
- SQL 스키마 생성
- 손에 쥔 용어장 초안(CSV/JSON) 만들기 →
ingest_terms.py
로 적재 - 커스텀 SQL 리트리버 또는 LangChain PGVector 중 하나 선택해 RAG 체인 연결
- 하이브리드 검색/도메인 필터/출처 표시 등 UX 개선
원하면:
- Chroma로 시작하는 초간단 버전
- pgvector 커스텀 리트리버 전체 코드(SQL+파이썬)
- Notion/Markdown/PDF 등 원천을 자동 크롤링해 용어장으로 변환하는 로더 스크립트
도 바로 만들어줄게.
먼저 어떤 경로(Chroma vs pgvector)로 갈지 말만 해줘.
답글 남기기