RAG 시스템으로 바이오 지식 검색 엔진 만들기 — 임베딩부터 시맨틱 서치까지
BioAI Market AI 챗봇의 환각을 해결하기 위해 RAG 아키텍처를 구축한 과정 — nomic-embed-text 임베딩, Supabase pgvector, 시맨틱 서치까지.
문제: LLM은 바이오마커를 모른다
BioAI Market의 AI 챗봇에게 "BRCA1이 관련된 질병은?"이라고 물으면, LLM은 자신 있게 대답했다. 문제는 그 답변의 상당 부분이 환각이었다는 것이다.
// LLM이 생성한 답변 (환각 포함)
Q: BRCA1이 관련된 질병은?
A: BRCA1은 다음 질병과 관련이 있습니다:
1. 유방암 (맞음)
2. 난소암 (맞음)
3. 전립선암 (부분적으로 맞음)
4. 췌장암 (부분적으로 맞음)
5. 대장암 (근거 약함)
6. 위암 (환각 — 주요 연관 없음)
7. 폐암 (환각 — 직접적 연관 없음)
7개 중 2개가 명백한 환각이었고, 2개는 근거가 약했다. 이런 답변이 연구자에게 전달되면 잘못된 연구 방향으로 이어질 수 있다.
해결책은 **RAG(Retrieval-Augmented Generation)**이었다. LLM이 자체 지식에 의존하는 대신, 검증된 데이터베이스에서 관련 정보를 검색하고 그 context를 기반으로 답변하도록 만드는 것이다.
RAG 아키텍처 설계
전체 흐름은 5단계다:
1. Query: "BRCA1이 관련된 질병은?"
↓
2. Embedding: query를 768차원 벡터로 변환
↓
3. Vector Search: Supabase pgvector에서 cosine similarity 검색
↓
4. Context Injection: top-5 결과를 LLM 프롬프트에 삽입
↓
5. LLM Response: context 기반으로 답변 생성
임베딩 모델: nomic-embed-text
모델 선택에서 가장 중요한 기준은 비용과 로컬 실행 가능 여부였다.
| 모델 | 차원 | 비용 | 로컬 실행 |
|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | $0.02/1M tokens | ❌ |
| Cohere embed-v3 | 1024 | $0.1/1M tokens | ❌ |
| nomic-embed-text | 768 | 무료 | ✅ (Ollama) |
| all-MiniLM-L6 | 384 | 무료 | ✅ |
nomic-embed-text를 선택했다. 768차원으로 all-MiniLM보다 풍부한 표현이 가능하면서, Ollama에서 로컬로 실행할 수 있어 비용이 ₩0이다.
# 모델 다운로드
ollama pull nomic-embed-text
# 크기: 274MB, 다운로드 약 1분
# 테스트
curl http://localhost:11434/api/embeddings \
-d '{"model": "nomic-embed-text", "prompt": "BRCA1 breast cancer biomarker"}'
# 응답: {"embedding": [0.0123, -0.0456, ...]} # 768개 float
벡터 DB: Supabase pgvector
Supabase의 PostgreSQL에 pgvector 확장을 활성화하면, 벡터 데이터를 네이티브로 저장하고 검색할 수 있다.
-- pgvector 활성화
CREATE EXTENSION IF NOT EXISTS vector;
-- 임베딩 컬럼 추가
ALTER TABLE biomarkers ADD COLUMN embedding vector(768);
ALTER TABLE diseases ADD COLUMN embedding vector(768);
무료 플랜(500MB)으로도 충분했다. 1141개 문서의 768차원 벡터가 약 3.5MB밖에 안 됐다.
임베딩 대상과 텍스트 구성
임베딩할 텍스트를 어떻게 구성하느냐가 검색 품질을 크게 좌우했다. 단순히 이름만 임베딩하면 시맨틱 매칭이 약하고, 너무 긴 텍스트를 임베딩하면 핵심 의미가 희석된다.
최종적으로 사용한 텍스트 구성:
// 바이오마커 임베딩 텍스트
function buildBiomarkerText(b: Biomarker): string {
return [
`Biomarker: ${b.name}`,
b.description ? `Description: ${b.description}` : '',
b.category ? `Category: ${b.category}` : '',
b.specimen_type ? `Specimen: ${b.specimen_type}` : '',
].filter(Boolean).join('. ');
}
// 예시 출력:
// "Biomarker: BRCA1. Description: Breast Cancer Type 1 Susceptibility Protein,
// DNA 손상 복구에 관여하는 종양억제 단백질. Category: Protein. Specimen: Tissue"
// 질병 임베딩 텍스트
function buildDiseaseText(d: Disease): string {
return [
`Disease: ${d.name}`,
d.description ? `Description: ${d.description}` : '',
d.icd_code ? `ICD: ${d.icd_code}` : '',
d.therapeutic_area ? `Therapeutic Area: ${d.therapeutic_area}` : '',
].filter(Boolean).join('. ');
}
총 1141개 문서를 벡터화했다:
- 바이오마커: 487개
- 질병: 312개
- 연관 관계: 342개
시맨틱 서치 구현
Supabase에서의 벡터 검색 함수:
-- RPC 함수 생성
CREATE OR REPLACE FUNCTION search_biomarkers(
query_embedding vector(768),
match_threshold float DEFAULT 0.7,
match_count int DEFAULT 5
)
RETURNS TABLE (
id uuid,
name text,
description text,
category text,
similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
b.id,
b.name,
b.description,
b.category,
1 - (b.embedding <=> query_embedding) AS similarity
FROM biomarkers b
WHERE 1 - (b.embedding <=> query_embedding) > match_threshold
ORDER BY b.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
TypeScript에서의 호출:
async function searchBiomarkers(query: string): Promise<SearchResult[]> {
// 1. 쿼리 임베딩
const queryEmbedding = await generateEmbedding(query);
// 2. Supabase RPC로 벡터 검색
const { data, error } = await supabase.rpc('search_biomarkers', {
query_embedding: queryEmbedding,
match_threshold: 0.7,
match_count: 5,
});
if (error) throw error;
return data;
}
// 사용 예시
const results = await searchBiomarkers("BRCA1 관련 질병");
// results: [
// { name: "BRCA1", similarity: 0.94, ... },
// { name: "BRCA2", similarity: 0.82, ... },
// { name: "HER2", similarity: 0.73, ... },
// ]
Context Injection과 환각 방지
검색된 결과를 LLM 프롬프트에 주입하는 방식:
function buildRAGPrompt(query: string, context: SearchResult[]): string {
const contextText = context.map(r =>
`- ${r.name}: ${r.description} (카테고리: ${r.category})`
).join('\n');
return `당신은 바이오마커 전문가 AI입니다.
반드시 아래 제공된 데이터베이스 정보만을 사용하여 답변하세요.
데이터베이스에 없는 정보는 절대 생성하지 마세요.
정보가 없으면 "해당 정보가 데이터베이스에 없습니다"라고 답하세요.
=== 데이터베이스 검색 결과 ===
${contextText}
========================
사용자 질문: ${query}`;
}
효과 측정
RAG 적용 전후의 답변 정확도를 비교했다:
RAG 적용 전:
- 정확한 정보: 60%
- 부분적 정확: 20%
- 환각: 20%
RAG 적용 후:
- 정확한 정보: 85%
- 부분적 정확: 10%
- 환각: 3%
- "정보 없음" 응답: 2%
환각이 20%에서 3%로 대폭 감소했다. 나머지 3%는 주로 LLM이 context의 정보를 과도하게 해석하는 경우였다.
한계와 개선 방향
RAG 시스템의 가장 큰 한계는 DB에 없는 정보에는 답할 수 없다는 것이다. "2025년에 새로 발견된 폐암 바이오마커는?"이라고 물으면, DB에 해당 정보가 없으면 "정보가 없습니다"라고 답한다.
이건 의도된 동작이다. 환각보다는 "모릅니다"가 낫다. 하지만 사용자 경험을 위해, "정보가 없습니다" 대신 관련 정보로 안내하는 fallback을 추가할 예정이다.
// Fallback 로직 (구현 예정)
if (results.length === 0) {
return "해당 정보가 현재 데이터베이스에 없습니다. " +
"관련 바이오마커를 검색해보시겠습니까? " +
"PubMed에서 최신 논문을 확인하는 것도 추천드립니다.";
}
Supabase pgvector 공식 문서와 nomic-embed-text 모델 카드를 참고했다.
💡 바이오마커 DB를 처음 구축한 과정은 genobalance.com의 바이오마커 데이터베이스 구축기에서 다루었다.
이 RAG 시스템의 기반이 된 프로테오믹스 분석 플랫폼은 sbmlab.com에서 확인할 수 있다.