AI

Supabase 사용해보기 (5) | Edge Function으로 약품 정보 검색하기

pepega 2026. 3. 8. 21:27

4편에서 Voyage AI로 임베딩을 생성하고 Supabase에 벡터 데이터를 적재했습니다. 이번 편에서는 적재된 데이터를 실제로 검색하는 search Edge Function을 구현하고, RAG 파이프라인을 완성하는 과정을 다룹니다.


이번 편에서 다루는 내용

  • search Edge Function 구현
  • similarity 임계값 설정 원리
  • Claude API로 최종 답변 생성
  • RAG 검색 품질 테스트

1. 검색 아키텍처

3편에서 등록한 search_medicines, search_medicines_hybrid 함수를 활용해 아래 흐름으로 검색합니다.

사용자 질문
    │
    ▼
[Voyage AI] → query 임베딩 생성 (input_type: "query")
    │
    ▼
[search_medicines] → top-1 similarity 확인 → 임계값 미만이면 차단
    │
    ▼
[search_medicines_hybrid] → 벡터 검색 + FTS → RRF 점수로 순위 통합
    │
    ▼
[Claude API] → 검색된 청크를 컨텍스트로 답변 생성
    │
    ▼
{ answer, sources }

참고: 저장 시 Voyage AI input_type은 "document", 검색 시에는 "query"로 구분합니다. 같은 모델이지만 용도에 따라 최적화된 임베딩을 생성해줍니다.


2. search Edge Function 작성

// supabase/functions/search/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
import Anthropic from "npm:@anthropic-ai/sdk";

const VOYAGE_API_KEY    = Deno.env.get("VOYAGE_API_KEY")!;
const ANTHROPIC_API_KEY = Deno.env.get("ANTHROPIC_API_KEY")!;
const SUPABASE_URL      = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_KEY      = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const API_SECRET_KEY    = Deno.env.get("API_SECRET_KEY")!;

const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
const claude   = new Anthropic({ apiKey: ANTHROPIC_API_KEY });

// 1. 질문을 query 타입 임베딩으로 변환
async function getQueryEmbedding(query: string): Promise<number[]> {
  const res = await fetch("https://api.voyageai.com/v1/embeddings", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${VOYAGE_API_KEY}`,
    },
    body: JSON.stringify({
      model: "voyage-3",
      input: [query],
      input_type: "query",  // 저장 시 "document", 검색 시 "query"
    }),
  });
  const data = await res.json();
  return data.data[0].embedding;
}

// 2. 관련 청크 검색
async function retrieve(query: string, topK = 5) {
  const embedding = await getQueryEmbedding(query);

  // top-1 similarity로 관련성 사전 판단 (임계값 없이 전체 검색)
  const { data: vectorData } = await supabase.rpc("search_medicines", {
    query_embedding:  embedding,
    match_threshold:  0.0,
    match_count:      1,
  });

  const topSimilarity = vectorData?.[0]?.similarity ?? 0;
  console.info(`query similarity top-1: ${topSimilarity.toFixed(4)}`);

  // 임계값 미만이면 무관한 질문으로 판단하고 차단
  if (topSimilarity < 0.35) return [];

  // Hybrid Search 실행
  const { data } = await supabase.rpc("search_medicines_hybrid", {
    query_text:      query,
    query_embedding: embedding,
    match_count:     topK,
  });

  return data ?? [];
}

// 3. 서버 핸들러
Deno.serve(async (req) => {
  const apiKey = req.headers.get("apikey");
  if (apiKey !== API_SECRET_KEY) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    const { query, top_k = 5 } = await req.json();

    if (!query) {
      return new Response(JSON.stringify({ error: "query is required" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    // 관련 청크 검색
    const chunks = await retrieve(query, top_k);
    if (chunks.length === 0) {
      return new Response(
        JSON.stringify({ answer: "관련 약품 정보를 찾을 수 없습니다." }),
        { headers: { "Content-Type": "application/json" } }
      );
    }

    // Claude로 답변 생성
    const context = chunks
      .map((c: { content: string }, i: number) => `[${i + 1}] ${c.content}`)
      .join("\n\n");

    const message = await claude.messages.create({
      model:      "claude-sonnet-4-20250514",
      max_tokens: 1024,
      system: `당신은 약품 정보 전문 안내 AI입니다.
주어진 약품 정보 컨텍스트를 바탕으로만 답변하세요.
컨텍스트에 없는 내용은 "해당 정보가 없습니다"라고 답하세요.
의학적 판단이 필요한 경우 반드시 의사/약사 상담을 권고하세요.`,
      messages: [{
        role: "user",
        content: `## 약품 정보\n${context}\n\n## 질문\n${query}`,
      }],
    });

    const answer = (message.content[0] as { text: string }).text;

    return new Response(JSON.stringify({ answer, sources: chunks }), {
      headers: { "Content-Type": "application/json" },
    });

  } catch (err) {
    return new Response(JSON.stringify({ error: String(err) }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
});
</number[]>

3. similarity 임계값 설정

임계값은 관련 없는 질문을 걸러내는 핵심 파라미터입니다.

retrieve() 함수에서 먼저 match_threshold: 0.0으로 top-1 similarity를 측정하고, 이 값이 임계값 미만이면 Hybrid Search를 아예 실행하지 않고 차단합니다. Edge Function 로그에서 아래처럼 확인할 수 있어요.

query similarity top-1: 0.8213  → 관련 있음, 검색 진행
query similarity top-1: 0.2130  → 관련 없음, 차단

Voyage-3 모델 기준으로 실제 측정한 분포는 아래와 같았습니다.

질문 유형 top-1 similarity

관련 있는 질문 0.75 ~ 0.85
관련 없는 질문 0.18 ~ 0.25

두 분포 사이인 0.35 근방을 임계값으로 설정하면 오탐/미탐을 모두 최소화할 수 있습니다. 임베딩 모델이 달라지면 이 분포도 달라지므로, 모델을 교체할 때는 반드시 재측정해야 합니다.

모델 평가 기준: 관련/무관 질문의 similarity 격차가 클수록 좋은 임베딩 모델입니다. Voyage-3는 관련 0.8대 / 무관 0.2대로 격차가 크고, 임베딩이 norm ≈ 1.0으로 정규화되어 있어 품질이 우수한 편입니다.


4. Edge Function 배포

supabase functions deploy search --no-verify-jwt

새 키 형식(sb_secret_)은 JWT가 아니므로 --no-verify-jwt 옵션이 필수입니다. (4편 참고)


5. RAG 검색 품질 테스트

검색 품질을 정량적으로 측정하는 Python 테스트 스크립트입니다.

"""
RAG 검색 품질 테스트 스크립트 (DB 실제 데이터 기반)
"""
import requests

SEARCH_URL = "https://{your-project-ref}.supabase.co/functions/v1/search"
SECRET_KEY = "sb_secret_..."
HEADERS = {
    "apikey":        SECRET_KEY,
    "Authorization": f"Bearer {SECRET_KEY}",
    "Content-Type":  "application/json; charset=utf-8",
}

# DB의 실제 chunk_type 기반으로 테스트 질문 설계
TEST_QUERIES = [
    # ✅ 관련 있는 질문 (chunk_type별로 커버)
    ("관련", "소화불량이나 체했을 때 먹는 약은?"),           # efcy
    ("관련", "기침이나 가래에 효과 있는 약은?"),             # efcy
    ("관련", "임신 중에 복용을 피해야 하는 약은?"),          # atpn
    ("관련", "만 2세 미만 소아에게 복용을 금지하는 약은?"),  # atpn
    ("관련", "식후에 복용해야 하는 약은?"),                  # useMethod
    ("관련", "두드러기나 발진 부작용이 있는 약은?"),          # se
    ("관련", "다른 약과 함께 먹으면 안 되는 약은?"),         # intrc
    # ⚠️ 무관한 질문
    ("무관", "오늘 날씨가 어때?"),
    ("무관", "맛있는 한국 음식 추천해줘"),
    ("무관", "주식 투자 좋은 종목 알려줘"),
    ("무관", "파이썬으로 웹 크롤링하는 방법은?"),
    ("무관", "서울에서 부산까지 KTX 요금은?"),
]

def search(query: str) -> list:
    res = requests.post(SEARCH_URL, headers=HEADERS,
                        json={"query": query, "top_k": 3})
    return res.json().get("sources", [])

def main():
    print("\n" + "🔬 RAG 검색 품질 테스트".center(60))
    print("※ RRF score는 순위 기반이라 절대값이 낮음 → 1위 결과의 item_name/chunk_label이 중요")

    passed, failed = 0, 0
    prev_category = None

    for category, query in TEST_QUERIES:
        if category != prev_category:
            print(f"\n\n{'━'*60}")
            print(f"  📂 [{category}] 질문")
            print(f"{'━'*60}")
            prev_category = category

        print(f"\n  🔍 {query}")
        results = search(query)

        if category == "무관":
            if not results:
                print("     ✅ 결과 없음 (정상)")
                passed += 1
            else:
                top = results[0]
                chunk_label = top.get("chunk_label", top.get("chunk_type", ""))
                print(f"     ❌ 오탐 발생! → {top['item_name']} / {chunk_label}")
                failed += 1
        else:
            if not results:
                print("     ❌ 결과 없음 (검색 실패)")
                failed += 1
            else:
                top = results[0]
                rest = results[1:]
                chunk_label = top.get("chunk_label", top.get("chunk_type", ""))
                content_key = f"{chunk_label}: "
                label_content = top["content"].split(content_key)[-1] if content_key in top["content"] else top["content"]
                print(f"     🥇 {top['item_name']} / {chunk_label}")
                print(f"          {label_content[:120]}...")
                for i, r in enumerate(rest, 2):
                    r_label = r.get("chunk_label", r.get("chunk_type", ""))
                    print(f"     [{i}] {r['item_name']} / {r_label}")
                passed += 1

    total = passed + failed
    score = passed / total * 100
    print(f"\n\n{'━'*60}")
    print(f"  📊 최종 결과: {passed}/{total} 통과 ({score:.0f}%)")
    grade = "🟢 우수" if score >= 80 else "🟡 보통" if score >= 60 else "🔴 개선 필요"
    print(f"  {grade}")
    print(f"{'━'*60}\n")

if __name__ == "__main__":
    main()

테스트 결과

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  📂 [관련] 질문
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  🔍 소화불량이나 체했을 때 먹는 약은?
     🥇 베스타제포르테정 / 효능
  🔍 기침이나 가래에 효과 있는 약은?
     🥇 바스칼캡슐 / 주의사항
  🔍 임신 중에 복용을 피해야 하는 약은?
     🥇 로와치넥스캡슐 / 주의사항
  🔍 만 2세 미만 소아에게 복용을 금지하는 약은?
     🥇 코푸시럽에스 / 주의사항
  🔍 식후에 복용해야 하는 약은?
     🥇 아락실과립 / 복용법
  🔍 두드러기나 발진 부작용이 있는 약은?
     🥇 알레리진정(세티리진염산염) / 부작용
  🔍 다른 약과 함께 먹으면 안 되는 약은?
     🥇 신일클로닉신리시네이트정 / 상호작용

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  📂 [무관] 질문
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  🔍 오늘 날씨가 어때?           ✅ 결과 없음 (정상)
  🔍 맛있는 한국 음식 추천해줘   ✅ 결과 없음 (정상)
  🔍 주식 투자 좋은 종목 알려줘  ✅ 결과 없음 (정상)
  🔍 파이썬으로 웹 크롤링하는 방법은?  ✅ 결과 없음 (정상)
  🔍 서울에서 부산까지 KTX 요금은?     ✅ 결과 없음 (정상)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  📊 최종 결과: 12/12 통과 (100%) 🎉
  🟢 우수
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

6. 마무리

이번 편의 핵심 요약입니다.

  • 저장 시 input_type: "document", 검색 시 input_type: "query" 구분 필수
  • similarity 임계값은 관련/무관 질문의 분포를 직접 측정한 후 그 사이 값으로 설정
  • 테스트 질문은 DB의 실제 데이터와 chunk_type을 기반으로 설계해야 정확한 품질 측정 가능

 

 

끝~