바이오마커 데이터베이스를 직접 만들어봤다 — 1141개 임베딩 구축기
BioAI Market AI 챗봇에 바이오마커/질병 지식을 주입하기 위해 Supabase + pgvector로 1141개 문서 임베딩을 구축한 경험을 공유한다.
왜 바이오마커 데이터베이스가 필요했나
BioAI Market을 개발하면서 가장 먼저 부딪힌 문제가 있었다. AI 챗봇에게 "PSA는 어떤 질병과 관련 있어?"라고 물으면, LLM이 그럴듯하지만 완전히 틀린 답변을 내놓는 거였다. 전립선암 관련 바이오마커라고는 하는데, 연관 질병 목록이나 수치가 전부 환각이었다.
이건 도메인 지식이 있는 사람이라면 바로 알아챌 수 있는 수준의 오류였다. 하지만 BioAI Market의 타겟 사용자 — 프로테오믹스 분석을 처음 접하는 연구자 — 는 이런 오류를 잡아내기 어렵다. 그래서 결론을 내렸다. 자체 바이오마커 데이터베이스를 만들고, RAG(Retrieval-Augmented Generation)로 LLM에 정확한 context를 주입해야 한다.
Supabase 테이블 설계
데이터베이스 스키마는 3개 테이블로 구성했다.
-- 바이오마커 테이블
CREATE TABLE biomarkers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
specimen_type TEXT,
owner_id UUID REFERENCES auth.users(id),
embedding vector(768),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(name, owner_id)
);
-- 질병 테이블
CREATE TABLE diseases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icd_code TEXT,
therapeutic_area TEXT,
embedding vector(768),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 연관 테이블
CREATE TABLE biomarker_diseases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
biomarker_id UUID REFERENCES biomarkers(id),
disease_id UUID REFERENCES diseases(id),
relationship_type TEXT,
evidence_level TEXT,
pubmed_id TEXT,
UNIQUE(biomarker_id, disease_id)
);
핵심 포인트는 embedding vector(768) 컬럼이다. 이걸 위해 Supabase에서 pgvector 확장을 활성화해야 했다.
CREATE EXTENSION IF NOT EXISTS vector;
Supabase 대시보드에서 SQL Editor로 실행하면 바로 활성화된다. 무료 플랜에서도 문제없이 동작했다.
임베딩 모델: nomic-embed-text 선택 이유
임베딩 모델 선택에서 고민이 많았다. OpenAI의 text-embedding-3-small이 성능은 좋지만, API 호출 비용이 발생한다. 1141개 문서를 반복적으로 임베딩해야 하는 상황에서 매번 비용이 나가면 부담이었다.
결국 nomic-embed-text를 선택했다. 이유는 세 가지였다:
- 무료 — Ollama에서 로컬로 구동 가능
- 768 차원 — OpenAI의 1536 차원보다 절반이라 저장 공간 절약
- 성능 — MTEB 벤치마크에서 오픈소스 모델 중 상위권
Ollama에서 모델 다운로드는 한 줄이면 끝이었다:
ollama pull nomic-embed-text
모델 크기는 약 274MB. RTX 3090 없이도 CPU만으로 돌릴 수 있었지만, 1141개 문서를 한꺼번에 처리할 때는 GPU가 있으면 확실히 빨랐다. CPU에서 약 15분, GPU에서 약 2분이었다.
1141개 문서 임베딩 프로세스
임베딩 대상은 다음과 같았다:
- 바이오마커: 이름 + 설명 + 카테고리 + specimen type을 하나의 텍스트로 결합
- 질병: 이름 + 설명 + ICD 코드 + therapeutic area 결합
- 연관 관계: 바이오마커명 + 질병명 + 관계 유형 + 근거 수준 결합
각 문서를 텍스트로 직렬화하고, Ollama API로 임베딩을 요청했다:
async function generateEmbedding(text: string): Promise<number[]> {
const response = await fetch('http://localhost:11434/api/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
prompt: text,
}),
});
const data = await response.json();
return data.embedding; // number[768]
}
Admin UI에 sync 버튼을 만들어서, 클릭 한 번으로 전체 임베딩을 갱신할 수 있게 했다. 실제로는 UPSERT로 이미 임베딩이 있는 문서는 건너뛰고, 새로 추가되거나 수정된 문서만 처리하도록 최적화했다.
최종 결과: 1141개 문서, 768차원 벡터, Supabase에 저장 완료.
RAG 파이프라인 구현
RAG 파이프라인의 흐름은 이렇다:
- 사용자가 "HER2와 관련된 질병은?"이라고 질문
- 질문을 nomic-embed-text로 임베딩 → 768차원 벡터
- Supabase에서 cosine similarity로 top-5 검색
- 검색된 문서를 LLM 프롬프트에 context로 주입
- LLM이 context 기반으로 답변 생성
Supabase에서의 벡터 검색 SQL:
SELECT
name, description, category,
1 - (embedding <=> $1) AS similarity
FROM biomarkers
WHERE 1 - (embedding <=> $1) > 0.7
ORDER BY similarity DESC
LIMIT 5;
<=> 연산자가 pgvector의 cosine distance다. 1 - distance로 similarity를 계산하고, 0.7 이상만 필터링했다. threshold를 0.5로 낮추면 너무 많은 결과가 나왔고, 0.8로 높이면 관련 문서를 놓치는 경우가 있었다. 0.7이 sweet spot이었다.
Anti-Hallucination 프롬프트
RAG를 붙여도 Ollama(qwen3:30b)는 여전히 환각을 만들었다. DB에 5개 질병만 연관되어 있는데 10개를 나열한다든지. 이걸 막기 위해 시스템 프롬프트에 다음을 추가했다:
You MUST only use information from the provided context.
If the context does not contain the answer, say "해당 정보가 데이터베이스에 없습니다."
Do NOT generate information that is not explicitly present in the context.
Do NOT invent biomarker-disease associations.
이렇게 해도 100% 막지는 못했다. 결국 중요한 수치(matchRate, DEP 개수 등)는 템플릿 기반 렌더링으로 전환했다. LLM이 숫자를 생성하지 않고, 코드에서 계산한 값을 템플릿에 삽입하는 방식이다. 이건 다음 글에서 자세히 다루겠다.
Supabase 무료 플랜에서 벡터 DB 운영하기
Supabase 무료 플랜의 저장 용량은 500MB다. 1141개 문서의 768차원 벡터가 차지하는 용량을 계산해보자:
- 768 dims × 4 bytes (float32) = 3,072 bytes/vector
- 1141 vectors × 3,072 bytes ≈ 3.5MB
벡터 자체는 3.5MB밖에 안 된다. 나머지 테이블 데이터까지 합해도 10MB 이내였다. 500MB 중 2%만 사용한 셈이다. 무료 플랜으로 충분히 운영 가능했다.
다만 주의할 점이 있다. pgvector의 ivfflat 인덱스를 만들면 인덱스 크기가 벡터 데이터의 1.5~2배까지 커질 수 있다. 1141개 정도면 인덱스 없이 brute-force 검색해도 충분히 빠르다 (응답 시간 약 50ms). 인덱스는 10,000개 이상일 때 고려하면 된다.
회고: 직접 만들어야 비로소 보이는 것들
바이오마커 DB를 직접 구축하면서 가장 크게 느낀 건, 데이터의 품질이 AI 시스템의 천장을 결정한다는 것이었다. PubMed에서 validated biomarker를 하나하나 확인하고 INSERT하는 과정이 지루했지만, 이 데이터가 정확하지 않으면 RAG 파이프라인 전체가 무너진다.
1141개라는 숫자가 많아 보이지만, 실제 임상에서 사용되는 바이오마커의 극히 일부에 불과하다. UniProt에 등록된 human protein이 20,000개가 넘는다는 걸 생각하면, 이건 시작에 불과하다.
다음 단계는 PubMed의 최신 논문에서 자동으로 바이오마커-질병 연관성을 추출하는 파이프라인을 만드는 것이다. 이건 또 다른 이야기가 될 것 같다.
💡 이 글에서 다룬 RAG 파이프라인의 기술적 구현은 kbrain-map.org의 RAG 시스템 글에서 더 자세히 다루었다.
BioAI Market의 프로테오믹스 분석 기능이 궁금하다면 sbmlab.com의 프로테오믹스 분석 플랫폼 소개도 참고하길 바란다.