개요
1편에서 Supabase 테이블과 검색 함수를 구성했습니다. 이번 편에서는 Supabase Edge Functions를 활용해 실제 약품 데이터를 벡터 DB에 적재하는 과정을 다룹니다.
이번 편에서 다루는 내용
- Supabase Edge Function 작성 (ingest)
- Voyage AI로 임베딩 생성
- 로컬 JSON 파일을 Edge Function으로 전송하는 Python 스크립트
1. Supabase 새 API 키 체계 이해
Edge Function을 작성하기 전에 Supabase의 새 API 키 체계를 이해해야 합니다.
Supabase는 기존의 JWT 기반 키(anon, service_role)에서 새로운 키 형식으로 전환 중입니다.
구분 기존 신규
| 공개 키 | anon (JWT) | sb_publishable_... |
| 비밀 키 | service_role (JWT) | sb_secret_... |
여기서 중요한 점이 있습니다. 새 키 형식(sb_publishable_, sb_secret_)은 JWT가 아니기 때문에 Edge Function의 JWT 검증을 통과할 수 없습니다.
Supabase 공식 문서에 따르면:
"Edge Functions only support JWT verification via the anon and service_role JWT-based API keys. You will need to use the --no-verify-jwt option when using publishable and secret keys."
따라서 새 키를 사용할 때는 두 가지 조치가 필요합니다.
- Edge Function 배포 시 --no-verify-jwt 옵션 사용
- Edge Function 코드 내부에서 직접 API 키 검증 구현
또한 Gateway를 통과하려면 Authorization: Bearer {key}와 apikey: {key} 헤더를 동일한 값으로 함께 전송해야 합니다.
2. Edge Function 작성 - ingest
프로젝트 구조
supabase/
└── functions/
├── ingest/
│ └── index.ts
└── search/
└── index.ts
ingest/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
const VOYAGE_API_KEY = Deno.env.get("VOYAGE_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);
// JSON 필드 → 청크 타입/라벨 매핑
const CHUNK_TYPES: Record<string, [string,="" string]=""> = {
efcyQesitm: ["efcy", "효능"],
useMethodQesitm: ["useMethod", "복용법"],
atpnWarnQesitm: ["atpnWarn", "경고"],
atpnQesitm: ["atpn", "주의사항"],
intrcQesitm: ["intrc", "상호작용"],
seQesitm: ["se", "부작용"],
depositMethodQesitm: ["deposit", "보관법"],
};
// Voyage AI 임베딩 일괄 생성
async function getEmbeddings(texts: 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: texts,
input_type: "document", // 저장 시 document
}),
});
const data = await res.json();
return data.data.map((d: { embedding: number[] }) => d.embedding);
}
// 검색 품질 향상을 위해 약품명/제조사 컨텍스트 포함
function buildChunkText(item: Record<string, string="">, label: string, content: string) {
return `약품명: ${item.itemName}\n제조사: ${item.entpName}\n${label}: ${content}`;
}
Deno.serve(async (req) => {
// 새 키 형식은 JWT 검증 불가 → 코드에서 직접 apikey 헤더 검증
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 { items } = await req.json();
for (const item of items) {
// 1. medicines 테이블에 원본 upsert
const { data: medData } = await supabase
.from("medicines")
.upsert({
item_seq: item.itemSeq,
item_name: item.itemName,
entp_name: item.entpName,
open_de: item.openDe,
update_de: item.updateDe,
item_image: item.itemImage,
bizrno: item.bizrno,
raw_json: item,
}, { onConflict: "item_seq" })
.select("id")
.single();
// 2. 항목별 청킹
const chunkMetas: { type: string; label: string; text: string }[] = [];
for (const [field, [chunkType, label]] of Object.entries(CHUNK_TYPES)) {
const content = item[field]?.trim();
if (!content) continue;
chunkMetas.push({
type: chunkType,
label: label,
text: buildChunkText(item, label, content),
});
}
if (chunkMetas.length === 0) continue;
// 3. 임베딩 일괄 생성 (API 호출 최소화)
const embeddings = await getEmbeddings(chunkMetas.map(c => c.text));
// 4. medicine_chunks insert
await supabase.from("medicine_chunks").insert(
chunkMetas.map((c, i) => ({
medicine_id: medData!.id,
item_seq: item.itemSeq,
item_name: item.itemName,
chunk_type: c.type,
chunk_label: c.label,
content: c.text,
embedding: embeddings[i],
}))
);
console.log(`✅ ${item.itemName} → ${chunkMetas.length}개 청크 삽입`);
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
return new Response(JSON.stringify({ error: String(err) }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
});
</string,></number[][]></string,>
핵심 포인트
- 임베딩을 청크마다 개별 호출하지 않고 배열로 한 번에 요청해서 API 호출 수를 최소화합니다.
- 청크 텍스트에 약품명: ...\n제조사: ...\n{라벨}: ... 형태로 컨텍스트를 포함시켜 청크 하나만 봐도 어떤 약인지 알 수 있게 합니다.
- 저장 시 Voyage AI input_type을 "document"로 설정합니다. (검색 시에는 "query"로 다릅니다.)
3. Secrets 설정
Edge Function에서 사용하는 환경변수를 Supabase Dashboard에 등록합니다.
Supabase Dashboard → Edge Functions → Secrets → Add
Name Value
| API_SECRET_KEY | sb_secret_... (Secret key) |
| VOYAGE_API_KEY | Voyage AI API 키 |
| ANTHROPIC_API_KEY | Anthropic API 키 |
SUPABASE_URL과 SUPABASE_SERVICE_ROLE_KEY는 Edge Function 환경에 자동으로 주입되므로 별도 등록 불필요합니다.
Voyage AI API 키는 dash.voyageai.com에서, Anthropic API 키는 console.anthropic.com에서 발급받을 수 있습니다.
4. Edge Function 배포
새 키 형식(sb_secret_)은 JWT가 아니므로 --no-verify-jwt 옵션을 반드시 추가해야 합니다.
supabase functions deploy ingest --no-verify-jwt
또는 Supabase Dashboard → Edge Functions → ingest → Details → Verify JWT with legacy secret → OFF 로 설정해도 동일합니다.
위 방식으로 해도 되고

supabase의 Deploy a new funtion으로 함수를 배포해도 됩니다.
--no-verify-jwt 옵션은 supabase의 UI 에서

버튼을 클릭하면 됩니다.
5. 데이터 적재 Python 스크립트
로컬의 medicines.json 파일을 Edge Function으로 전송하는 스크립트입니다.
medicines.json 은 https://www.data.go.kr/data/15075057/openapi.do?recommendDataYn=Y 에서 가져왔습니다.
pip install requests
import json
import time
import requests
INGEST_URL = "https://{your-project-ref}.supabase.co/functions/v1/ingest"
SECRET_KEY = "sb_secret_..." # Secret key
BATCH_SIZE = 10 # 한 번에 보낼 약품 수
def send_batch(items: list, batch_num: int) -> bool:
try:
res = requests.post(
INGEST_URL,
headers={
"apikey": SECRET_KEY,
"Authorization": f"Bearer {SECRET_KEY}", # 두 헤더 값이 동일해야 Gateway 통과
"Content-Type": "application/json",
},
json={"items": items},
timeout=60,
)
if res.status_code == 200:
print(f"✅ 배치 {batch_num} 완료 ({len(items)}개)")
return True
else:
print(f"❌ 배치 {batch_num} 실패: {res.status_code} - {res.text}")
return False
except Exception as e:
print(f"❌ 배치 {batch_num} 오류: {e}")
return False
def main():
with open("medicines.json", "r", encoding="utf-8") as f:
data = json.load(f)
items = data["body"]["items"]
total = len(items)
print(f"📦 총 {total}개 약품 데이터 적재 시작\n")
failed_batches = []
for i in range(0, total, BATCH_SIZE):
batch = items[i:i + BATCH_SIZE]
batch_num = i // BATCH_SIZE + 1
success = send_batch(batch, batch_num)
if not success:
failed_batches.append(batch_num)
time.sleep(1) # API 레이트 리밋 방지
print(f"\n{'='*40}")
if not failed_batches:
print("🎉 전체 성공!")
else:
print(f"❌ 실패 배치: {failed_batches} → 재시도 필요")
if __name__ == "__main__":
main()
헤더 설정 주의사항
새 키 형식을 사용할 때는 apikey와 Authorization: Bearer 헤더를 동일한 값으로 함께 전송해야 합니다. 하나만 보내면 Gateway에서 401이 반환됩니다.
6. 적재 결과 확인
Supabase Dashboard → SQL Editor에서 확인합니다.
-- 약품별 청크 개수 확인
select item_name, count(*) as chunk_count
from medicine_chunks
group by item_name
order by item_name;
-- 특정 키워드 포함 청크 조회
select item_name, chunk_label, content
from medicine_chunks
where content like '%임신%';
마무리
이번 편에서는 Supabase Edge Function을 활용한 데이터 적재 과정을 다뤘습니다.
핵심 요약
- 새 Supabase 키(sb_secret_)는 JWT가 아니므로 --no-verify-jwt 옵션 필수
- apikey와 Authorization: Bearer 헤더를 동일한 값으로 함께 전송
- 코드 내부에서 apikey 헤더로 직접 인증 검증
- 임베딩 생성 시 input_type: "document" (저장) / "query" (검색) 구분 필수
다음 편에서는 적재된 데이터를 Edge Function으로 자연어 검색하고 Claude가 답변을 생성하는 과정을 다루겠습니다.
'AI' 카테고리의 다른 글
| Supabase 사용해보기 (5) | Edge Function으로 약품 정보 검색하기 (0) | 2026.03.08 |
|---|---|
| Supabase 사용해보기 (3) | Supabase Vector DB 구성하기 (0) | 2026.01.31 |
| Supabase 사용해보기 (2) | Supabase 구성하기 (0) | 2026.01.12 |
| Supabase 사용해보기 (1) | 개요 (0) | 2026.01.06 |