Bioinformatics

바이오마커 DB에 시맨틱 검색을 붙이며 배운 것 — 임베딩 1,141개, pgvector, 그리고 시드 데이터

BioAI Market의 바이오마커·질병 시맨틱 검색을 nomic-embed-text와 pgvector로 만든 과정. 저장소를 다시 읽으며 찾은 실수(빠진 쿼리 접두어, 호출마다 다른 임계값, random()으로 채운 점수 칸)까지 적었다.

·22 min read
#RAG#임베딩#pgvector#nomic-embed-text#바이오마커#BioAI

BioAI Market은 내가 2025년 12월부터 만든 웹 기반 오믹스 분석 플랫폼이다. 분석 파이프라인 이야기는 프로테오믹스 분석 플랫폼을 직접 만들어봤다에 썼다. 이 글은 그 플랫폼 안의 바이오마커·질병 DB에 시맨틱 검색(RAG)을 붙인 이야기다.

예전에 이 내용을 다른 블로그 세 곳에 나눠 올렸다. 다시 읽어 보니 정확도 표, 처리 시간, 임베딩 건수 내역처럼 지금 확인할 수 없는 숫자가 섞여 있었다. 실제 코드와 다른 스키마도 있었다. 그래서 git 저장소의 커밋과 코드만 근거로 다시 썼다. 날짜와 숫자는 모두 커밋 기록에서 가져왔다. 확인할 수 없는 것은 뺐다.

무엇을 임베딩했나

RAG 테이블은 2026년 1월 2일 커밋("Phase 5 & 6 - RAG System, AI Chat, …")에 들어갔다. 원본 biomarkers, diseases 테이블에 벡터 칸을 붙이지 않았다. 대신 임베딩을 별도 테이블로 뺐다.

  • biomarker_embeddings: 바이오마커 하나에 텍스트 종류(content_type)별로 벡터 하나
  • disease_embeddings: 질병 하나에 벡터 하나
  • knowledge_chunks: 문서 조각

각 행에는 벡터만 있는 게 아니라 실제로 임베딩한 텍스트(content), 그 텍스트의 SHA-256 해시(content_hash), 모델 이름(model)이 같이 들어 있다. 덕분에 지금도 어떤 문장을 어떤 모델로 벡터로 바꿨는지 행 단위로 확인할 수 있다.

처음 스키마는 OpenAI text-embedding-3-small 기준의 1,536차원이었다. 같은 커밋 안의 다음 마이그레이션 파일이 이것을 Ollama nomic-embed-text의 768차원으로 바꾼다. 그 파일 마지막에는 이런 주석이 있다.

-- 저장소 supabase/migrations/20260101100000_ollama_embeddings_768.sql 에서 발췌
-- 6. Clear existing embeddings (they're incompatible)
-- Note: You'll need to regenerate all embeddings with the new model

차원이 다른 모델로 바꾸면 기존 벡터는 하나도 재사용할 수 없다. 당연한 이야기지만, 모델을 정하기 전에 임베딩을 대량으로 만들어 두면 그만큼 버리게 된다.

임베딩한 텍스트

바이오마커 쪽은 DB 함수가 기호, 이름, 설명, 유형, 임상적 의의를 한 줄로 이어 붙인다.

-- 저장소 supabase/migrations/20260101000000_rag_system.sql 의
-- get_biomarkers_needing_embeddings() 에서 발췌
WHEN 'full' THEN
    COALESCE(b.symbol, '') || ' - ' ||
    COALESCE(b.name, '') || '. ' ||
    COALESCE(b.description, '') || ' ' ||
    'Type: ' || COALESCE(b.type, '') || '. ' ||
    COALESCE(b.clinical_significance, '')

질병 쪽은 TypeScript 인덱서(src/lib/rag/indexer.ts)가 이름, 치료 영역, 설명, 증상을 같은 방식으로 잇는다. 바이오마커-질병 연관 관계는 따로 임베딩하지 않았다. 연관 관계용 임베딩 테이블이 없다.

1,141이라는 숫자

예전 글들에서 "1,141개"를 바이오마커, 질병, 연관 관계로 나눈 내역을 적었는데, 그 내역은 저장소에서 확인되지 않는다. 확인되는 것은 2026년 2월 16일 커밋("integrate RAG embeddings into AI Agent engine")에 들어간 코드 주석 한 줄이다.

// 저장소 src/lib/agents/agentic-engine.ts 에서 발췌
// RAG 시맨틱 검색 — 바이오마커/질병 임베딩 (1141개) 활용

그러니 1,141은 그 시점에 있던 바이오마커와 질병 임베딩을 합한 수다. 768차원 float32 벡터 1,141개면 벡터 값만 약 3.5MB(1,141 × 768 × 4바이트)라서, 저장 공간은 걱정할 일이 아니었다.

검색 함수

검색은 pgvector의 코사인 거리 연산자 <=>를 쓴다. 유사도는 1 - 거리로 계산하고, 임계값보다 높은 것만 가까운 순서로 돌려준다.

-- 저장소 supabase/migrations/20260101100000_ollama_embeddings_768.sql 에서 발췌
CREATE OR REPLACE FUNCTION search_biomarkers_semantic(
    query_embedding vector(768),
    match_threshold float DEFAULT 0.7,
    match_count int DEFAULT 10
)
...
    FROM biomarker_embeddings be
    JOIN biomarkers b ON b.id = be.biomarker_id
    WHERE 1 - (be.embedding <=> query_embedding) > match_threshold
    ORDER BY be.embedding <=> query_embedding
    LIMIT match_count;

인덱스는 HNSW(m = 16, ef_construction = 64, vector_cosine_ops)로 처음부터 만들어 두었다. 2월 24일 커밋("hybrid RAG - local PostgreSQL for semantic search with Supabase fallback") 이후로는 GPU 서버의 PostgreSQL에서 같은 방식으로 검색하고, 실패하면 Supabase로 넘어간다.

키워드 점수(ts_rank)와 시맨틱 점수를 0.3 대 0.7로 섞는 search_biomarkers_hybrid 함수도 만들었다. 그런데 앱 코드에서 이 함수를 부르는 곳은 없다. 다시 쓴다면 손볼 곳도 있다. ts_rank 값과 코사인 유사도는 척도가 달라서, 그대로 가중합하면 한쪽이 결과를 좌우하기 쉽다.

지금 보니 틀린 것 1: nomic-embed-text의 접두어

임베딩을 만드는 코드는 텍스트를 그대로 보낸다.

// 저장소 src/lib/rag/embeddings.ts 에서 발췌 (주소·인증 부분 생략)
body: JSON.stringify({
  model: EMBEDDING_MODEL,   // 'nomic-embed-text'
  prompt: truncatedText,
}),

nomic-embed-text는 작업 접두어를 붙여 쓰도록 만든 모델이다. 검색 대상 문서에는 search_document: , 검색 질문에는 search_query: 를 앞에 붙여야 한다(모델 카드에 명시돼 있다). 나는 문서와 질문 모두 접두어 없이 넣었다. 그래서 검색 품질에서 손해를 봤을 가능성이 높고, 유사도 값의 분포도 접두어를 붙였을 때와 달라진다. 접두어 없이 맞춘 임계값은 접두어를 붙인 뒤에는 다시 맞춰야 한다.

고친다면 이런 모양이 된다. 아래는 단순화한 예시이고, 저장소 코드가 아니다.

// 단순화한 예시 — 저장소 코드 아님
const OLLAMA = process.env.OLLAMA_BASE_URL

async function embed(texts: string[]): Promise<number[][]> {
  // /api/embed 는 input 에 문자열 배열을 받는다
  const res = await fetch(`${OLLAMA}/api/embed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'nomic-embed-text', input: texts }),
  })
  const data = await res.json()
  return data.embeddings
}

// 문서를 넣을 때
const docVectors = await embed(docs.map(d => `search_document: ${d}`))

// 질문으로 검색할 때
const [queryVector] = await embed([`search_query: ${question}`])

예전 코드에는 "Ollama는 배치 임베딩을 지원하지 않아 한 건씩 처리한다"는 주석도 있다. 지금 Ollama의 /api/embed는 위처럼 배열을 한 번에 받는다. 접두어를 붙이면 기존 벡터를 전부 다시 만들어야 하니, 이 둘은 같이 고치는 게 낫다.

언어 문제도 있다. DB의 바이오마커 설명은 영어로 들어 있는데, 채팅과 에이전트의 프롬프트는 한국어로 짜여 있어서 질문도 한국어로 들어온다고 봐야 한다. nomic-embed-text는 영어 위주로 학습된 모델이다. 한국어 질문과 영어 문서가 얼마나 잘 맞는지 따로 시험한 기록은 저장소에 없다. 한국어 질문이 주력이라면 bge-m3 같은 다국어 임베딩 모델을 후보에 넣고, 질문을 영어로 바꿔 검색하는 방법과 비교해 봐야 한다. bge-m3는 1,024차원이라 그때도 전부 다시 임베딩해야 한다.

지금 보니 틀린 것 2: 호출하는 곳마다 다른 임계값

예전 글에는 "임계값 0.7이 최적이었다"고 적었다. 코드를 보면 그렇게 정리된 적이 없다. 기본값은 0.7이지만, 실제 호출부는 각자 다른 값을 넘긴다.

호출하는 곳임계값
기본값 (src/lib/rag/search.ts)0.7
일반 AI 채팅0.6
프로젝트 채팅0.55
분석 결과 해석0.5
프로젝트 데이터 검색0.45
분석 에이전트0.3

각 값을 어떤 근거로 정했는지는 기록이 없다. 게다가 프로젝트 데이터 검색 결과에는 유사도에 1.3을 곱해 순위를 올린다. 에이전트는 그 값을 relevance: N% 형태로 프롬프트에 넣는다. 그러면 관련도가 100%를 넘는 숫자가 LLM에게 전달될 수 있다. 숫자처럼 보이지만 의미가 없는 값이다.

임계값은 감으로 정할 게 아니었다. 정답을 아는 질문 몇십 개를 만들어 두고(이 질문에는 이 바이오마커가 나와야 한다), 접두어를 붙인 상태에서 임계값별로 몇 개를 찾고 몇 개를 놓치는지 세어 정했어야 했다.

지금 보니 틀린 것 3: 시드 데이터

RAG는 DB에 있는 내용을 찾아 줄 뿐이다. DB가 틀리면 검색도 틀린 걸 정확하게 찾아온다. 저장소의 시드 마이그레이션을 다시 읽어 보니 문제가 여럿 있었다.

ON CONFLICT가 중복을 막지 못했다

biomarkers 테이블의 유일 제약은 UNIQUE (name, owner_id)다. 공개 시드 데이터는 owner_id를 비워 둔다(NULL). PostgreSQL은 기본적으로 유일 제약에서 NULL끼리를 서로 다른 값으로 본다. 그래서 공개 시드에 같은 이름을 두 번 넣어도 충돌이 나지 않는다. 마이그레이션 곳곳에 있는 ON CONFLICT (name, owner_id) DO NOTHING은 공개 시드에서는 아무것도 막지 못한다.

1월 6일 커밋에는 대량 삽입 마이그레이션이 네 벌(원본, FIXED, v2, v3) 들어 있다. v3 파일 머리에는 Fixed: Use DO $$ block to avoid duplicate inserts / Checks if name exists before inserting라고 적혀 있다. 행마다 IF NOT EXISTS (SELECT 1 FROM biomarkers WHERE name = ...)로 확인하는 방식으로 돌아간 것이다.

처음부터 했어야 하는 방법은 둘 중 하나다. 아래는 단순화한 예시다.

-- 단순화한 예시 — 저장소 코드 아님
-- (1) PostgreSQL 15 이상: NULL도 같은 값으로 취급
ALTER TABLE biomarkers
  ADD CONSTRAINT biomarkers_name_owner_key UNIQUE NULLS NOT DISTINCT (name, owner_id);

-- (2) 공개 행에만 이름 유일 인덱스를 따로 두고, 충돌 대상을 그 인덱스로 지정
CREATE UNIQUE INDEX biomarkers_public_name_key
  ON biomarkers (name) WHERE owner_id IS NULL;

INSERT INTO biomarkers (name, symbol, ...)
VALUES (...)
ON CONFLICT (name) WHERE owner_id IS NULL DO NOTHING;

같은 마커가 다른 이름으로

이름으로 중복을 확인해도 같은 마커가 다른 이름으로 들어오는 것은 못 막는다. 마이그레이션 파일 기준으로 보면 혈중 p-tau217이 두 번 들어간다. v3 파일에는 이름 Phospho-Tau 217, 기호 p-Tau217로 들어가고, 같은 날의 확장 파일에는 이름 Phosphorylated Tau 217, 기호 p-tau217로 들어간다. 이름이 달라서 둘 다 삽입된다. 질병 연관을 거는 쪽은 기호를 대소문자까지 정확히 비교한다. v3 쪽 연관은 p-Tau217 행에, 확장 쪽 연관은 p-tau217 행에 붙는다. 같은 마커인데 어느 행을 보느냐에 따라 연관 질병이 다르게 보일 수 있는 구조다. 이틀 뒤(1월 8일 커밋) 검체 정보를 채우는 마이그레이션은 아예 WHERE symbol IN ('p-Tau217', 'p-tau217')로 두 기호를 모두 잡도록 쓰여 있다.

실패를 조용히 삼키는 함수

질병 연관을 대량으로 넣는 도우미 함수는 이렇게 생겼다.

-- 저장소 supabase/migrations/20260106140000_disease_associations_mega.sql 에서 발췌
    WHERE b.symbol = p_biomarker_symbol AND d.name = p_disease_name
    ON CONFLICT (biomarker_id, disease_id) DO NOTHING;
EXCEPTION WHEN OTHERS THEN
    -- Silently ignore if biomarker or disease doesn't exist
    NULL;

기호나 질병 이름이 한 글자만 틀려도 그 연관은 오류 없이 빠진다. 몇 건이 빠졌는지 알 방법도 없다. 마이그레이션이 끝난 뒤 "넣으려던 연관 수"와 "실제로 들어간 연관 수"를 비교하는 확인 쿼리 하나만 있었어도 드러났을 문제다.

random()으로 채운 점수 칸

가장 뼈아픈 것은 이것이다. 1월 6일 커밋에 들어간 연관 관계 마이그레이션 세 벌은 점수 칸을 난수로 채운다.

-- 저장소 supabase/migrations/20260103100006_biomarker_disease_associations_v3.sql 에서 발췌
INSERT INTO biomarker_disease_associations
  (biomarker_id, disease_id, direction, evidence_level, disgenet_score, literature_count, consistency_score)
SELECT
    b.id,
    ...
    0.7 + random() * 0.3,             -- disgenet_score
    floor(100 + random() * 500)::int, -- literature_count
    0.7 + random() * 0.3              -- consistency_score
FROM biomarkers b
WHERE b.primary_disease = 'neurodegenerative'
AND b.symbol IN ('Abeta42', 'Abeta40', 't-Tau', 'p-Tau181', 'p-Tau217', ...)

칸 이름은 disgenet_score, literature_count인데, 값은 DisGeNET에서 온 것도 문헌 수를 센 것도 아니다. 자리를 채우려고 넣은 난수다. 이 마이그레이션에서 맞는 정보도 있다. CSF Aβ42는 알츠하이머에서 감소하는 쪽(down)으로, 나머지는 증가하는 쪽으로 넣었는데, 이 방향은 맞다. 문제는 그 옆 칸들이 진짜 점수처럼 보인다는 데 있다.

이 점수는 그냥 묻혀 있지 않았다. DE 결과를 DB와 대조하는 도구는 연관 질병을 disgenet_score 내림차순으로 고른다. 결과 템플릿은 그 값을 (score: …)로 그대로 찍는다. LLM 환각을 막으려고 만든 템플릿이 DB에 들어 있는 가짜 숫자는 그대로 내보내는 구조였다. 이 이야기는 다음 글에서 이어서 다룬다.

같은 칸의 방향이 파일마다 다른 것도 있다. biomarkers.evidence_level은 15 정수다. v3 대량 삽입 파일에서는 validated 마커에 1이나 2를 넣었다. 1월 6일 확장 파일과 2월 17일 추가 파일에서는 validated 마커에 35를 넣었다. 어느 쪽이 강한 근거인지가 파일마다 반대로 보인다.

출처 칸이 비어 있다

연관 테이블에는 source_pmids, first_reported_pmid 칸이 있지만, 위의 대량 삽입 경로들은 이 칸을 채우지 않는다. p-tau217과 알츠하이머의 연관에도 근거 논문이 붙어 있지 않다. 예전 글에는 이 연관의 근거로 PMID 35771652를 적었는데, 그 PMID는 교정치과 논문(상악 확장 장치 연구)이다. p-tau217에 근거를 붙인다면 혈장 p-tau217로 알츠하이머와 다른 신경퇴행 질환을 구별한 Palmqvist 등의 JAMA 2020 논문(PMID 32722745) 같은 원 논문이어야 한다. 그리고 PMID는 손으로 옮겨 적지 말고 PubMed에서 제목을 다시 불러와 맞는지 확인하고 넣어야 한다.

DB가 커진 뒤: 8만 건이라고 부르지 않은 이유

2월 말에 바이오마커 데이터를 GPU 서버의 로컬 PostgreSQL로 옮기면서 규모가 커졌다. 2월 25일 커밋 메시지에는 바이오마커 8만 2천여 건, 질병 7,551건, 연관 34,246건이 적혀 있다. 다음 날 커밋("biomarker tier classification system")이 이 숫자를 등급으로 나눈다.

등급기준 (커밋 메시지)건수
Tier 1임상적 의의가 있거나, 근거가 세 갈래이거나, PMID 연관이 3개 이상1,761
Tier 2PMID가 붙은 질병 연관2,337
Tier 3CTD에서 큐레이션된 질병 연관5,476
Tier 4UniProt reviewed 단백질, 질병 연관 없음73,403

8만여 건 가운데 7만 3천여 건은 질병 연관이 없는 참조 단백질이다. 이것을 "바이오마커 8만 개"라고 부르면 과장이다. 그래서 화면 문구를 등급 중심으로 바꿨다. 커밋 메시지 표현으로는 "honest labeling"이다. 다만 1,141개 임베딩은 이 확장 이전 시점의 숫자다. 확장된 DB를 얼마나 다시 임베딩했는지는 커밋만으로는 알 수 없어서 적지 않는다.

정리

  • 임베딩 모델과 차원은 데이터를 대량으로 넣기 전에 정한다. 바꾸면 전부 다시 만든다.
  • 모델 카드의 사용법(접두어 같은 것)을 먼저 읽는다. 나는 이것을 빼먹었다.
  • 임계값은 정답을 아는 작은 질문 세트로 정하고, 한 곳에서 관리한다.
  • 시드 데이터에 난수나 자리 채움 값을 넣었다면, 실제 점수처럼 보이는 칸에는 넣지 않는다. 넣었다면 화면과 프롬프트에 나가기 전에 지운다.
  • NULL이 들어가는 유일 제약은 생각대로 동작하지 않는다. 삽입 뒤에는 건수 확인 쿼리를 돌린다.

RAG는 DB에 있는 것을 찾아오는 장치다. DB를 검사하는 장치는 아니다. 이 프로젝트에서 LLM 출력을 어떻게 다뤘는지는 로컬 LLM에게 분석 결과 설명을 맡겼다가 템플릿으로 바꾼 이유에, 프로젝트 전체 회고는 BioAI Market 1인 개발 회고에 정리했다.

관련 글