브라우저에서 임베딩 검색을 돌린다고? Ternlight로 보는 WASM 온디바이스 ML의 현실
1. 왜 지금 이게 화제인가
임베딩 기반 시맨틱 검색을 붙여본 사람이라면 다들 겪는 패턴이 있다. 사용자 입력 → 임베딩 API 호출(OpenAI든 자체 모델이든) → 벡터 DB 조회 → 결과 정렬. 여기서 첫 번째 임베딩 호출이 항상 목에 걸린다. 네트워크 왕복 100~300ms, API 요율 제한, 그리고 검색 트래픽이 늘수록 선형으로 늘어나는 비용. FAQ 매칭이나 문서 검색 같은 "가벼운" 기능 하나 붙이자고 임베딩 서버 인프라를 굴려야 하는 상황이 꽤 흔하다.
Ternlight는 이 지점을 정면으로 건드린다. 엔진과 가중치를 합쳐 7MB(mini 변형은 5MB), GPU 없이 CPU에서만 돌고, 서버 호출이 아예 없다. 텍스트를 넣으면 브라우저 안에서 384차원 벡터가 나오고, 코사인 유사도로 관련성을 판단한다. 원문 기준 임베딩당 약 5ms(mini는 약 2.5ms)라고 한다. MiniLM에서 작은 문장 인코더를 증류하고, ternary 양자화 인식 학습(quantization-aware training)을 적용한 뒤 추론 엔진을 Rust로 직접 짜서 WASM SIMD로 배포한 구조다.
인프라 엔지니어 입장에서 이게 흥미로운 이유는 명확하다. 임베딩 서버를 통째로 없앨 수 있는 아키텍처 선택지가 생긴다는 것. 다만 "없앨 수 있다"와 "없애야 한다"는 완전히 다른 얘기고, 그 경계를 아는 게 이 글의 핵심이다.
2. 동작 원리: 텍스트가 벡터가 되기까지
WASM에서 ML 추론이 가능한 이유
먼저 "브라우저에서 모델이 돈다"는 게 마법이 아니라는 점부터. 브라우저는 이제 WASM 바이트코드를 거의 네이티브에 가까운 속도로 실행한다. 여기에 WASM SIMD(단일 명령으로 여러 데이터를 병렬 처리)가 붙으면 벡터·행렬 연산이 크게 빨라진다. 임베딩 모델 추론은 결국 행렬 곱셈 덩어리라서 SIMD가 잘 먹힌다.
Ternlight가 Rust로 엔진을 직접 짠 이유가 여기 있다고 본다. Transformers.js처럼 ONNX 런타임을 통째로 얹으면 편하지만 번들이 커진다. 필요한 연산만 Rust로 구현하고 WASM SIMD로 컴파일하면 7MB까지 줄일 수 있는 것이다. 여기서 "7MB"는 대부분 양자화된 가중치 무게라고 보는 게 맞다.
7MB로 줄이는 핵심: ternary 양자화
보통 모델 가중치는 float32(4바이트)로 저장한다. ternary 양자화는 가중치를 -1, 0, +1 세 가지 값으로 압축한다. 정보 손실은 있지만, 양자화 인식 학습으로 그 손실을 학습 단계에서 보정한다. 이게 크기를 극적으로 줄이는 핵심이다.
HN 댓글에서 나온 mini 버전 분석도 참고할 만하다. mini는 내부적으로 384가 아니라 256요소 벡터를 쓰고, 마지막에 호환성을 위해 384로 투영하는 것으로 보인다. 크기는 1/3 줄지만 정보 손실은 선형적이지 않아서 1/3보다 적어 보인다는 관찰이다. (공식 문서 확인 필요 — 어디까지나 커뮤니티 추정이다.)
실제 파이프라인
사용 흐름은 정말 단순하다. npm 패키지 하나 설치하고 함수 두 개 가져오면 끝이다.
npm install @ternlight/base
import { embed, similar } from '@ternlight/base';
const recipes = [
'10분 안에 만드는 파스타',
'주말 브런치용 팬케이크 레시피',
'평일 저녁 간단한 볶음밥',
];
// 쿼리와 후보 배열을 넣으면 상위 K개가 정렬돼서 나온다
const results = similar('easy weeknight dinner ideas', recipes, { topK: 3 });
console.log(results);
// → ranked matches · ~5 ms · zero network
여기서 중요한 포인트. similar()는 내부적으로 쿼리와 후보들을 전부 embed()해서 코사인 유사도를 계산한다. 즉 후보가 매번 다시 임베딩된다면 후보 개수만큼 임베딩 비용이 든다. React 문서 2천 개를 검색하는 데모가 있는데, 이건 후보 임베딩을 미리 계산해두고 재사용하는 구조여야 실용적이다. 실무에서는 이렇게 나눠 쓴다.
import { embed, similar } from '@ternlight/base';
// 1. 색인 단계: 문서 임베딩은 한 번만 계산해서 캐시
// (빌드 타임에 서버에서 돌려 JSON으로 내려도 된다)
const docs = ['문서 A 내용...', '문서 B 내용...', /* ... 2000개 */];
const docEmbeddings = docs.map(d => embed(d)); // 최초 1회
// 2. 검색 단계: 쿼리만 임베딩하고 미리 만든 벡터와 비교
const queryVec = embed('how to use createContext');
// 코사인 유사도로 상위 K 정렬 (라이브러리 API는 저장소 확인 필요)
HN 스레드에서도 "30초 걸리는 임베딩 생성을 서버에서 미리 해두고 벡터만 프런트로 보낼 수 있냐"는 질문에 저자가 "가능하다, 서버에서 한 번만 색인 돌리고 임베딩만 프런트엔드로 보내면 된다"고 답했다. 이게 정석적인 사용 패턴이다.
3. 실무 관점: 트레이드오프와 흔한 함정
서버리스 vs 클라이언트 사이드 추론 — 비용/레이턴시 계산
단순히 "서버 없애면 좋다"가 아니라 실제로 계산해봐야 한다.
- 서버 임베딩 방식: 첫 검색 레이턴시에 네트워크 왕복(100~300ms) + 서버 추론 시간이 붙는다. 대신 클라이언트는 아무것도 다운로드하지 않는다. 트래픽 비례 비용 발생.
- Ternlight(클라이언트) 방식: 첫 진입 시 5~7MB 다운로드 + WASM 컴파일/인스턴스화라는 초기 비용이 크다. 대신 그 이후 검색은 네트워크 0회, 서버 비용 0. 사용자 CPU를 빌려 쓴다.
핵심은 초기 로딩 비용이 검색 횟수로 상각되는가다. 사용자가 페이지에서 검색 한 번 하고 나갈 서비스라면 7MB 다운로드가 오히려 손해다. 반대로 문서 사이트에서 세션 내내 여러 번 검색하는 패턴이면 클라이언트 방식이 압도적으로 유리하다.
흔한 함정 1: SIMD 경로로 안 떨어지면 성능이 폭락한다
이게 가장 실질적인 함정이다. HN에서 한 사용자가 i5-4570 + Firefox 환경에서 "주장한 초당 400개가 아니라 초당 35개 임베딩밖에 안 나온다"고 보고했다. 저자가 SIMD가 아닌 경로(fallback)로 떨어지는 것 아니냐는 의심이 나왔다. 즉 WASM SIMD를 지원하지 않는 브라우저/환경에서는 스칼라 경로로 폴백하면서 성능이 10배 이상 느려질 수 있다는 얘기다.
WASM SIMD를 지원하지 않는 환경에서 SIMD 바이너리를 로드하려 하면 대략 이런 에러를 만난다.
CompileError: WebAssembly.instantiate(): Compiling function #12 failed:
Invalid opcode 0xfd (SIMD not enabled) @+1834
또는 Node.js 구버전에서 플래그 없이 돌릴 때:
CompileError: WebAssembly.compile(): invalid value type 'Simd128', enable with --experimental-wasm-simd @+42
대응은 두 가지. 우선 타깃 사용자의 브라우저 SIMD 지원 여부를 배포 전에 파악해야 한다. 크롬/파폭/사파리 최신 버전은 SIMD를 지원하지만, 구형 기기나 특정 임베디드 웹뷰는 다르다. 다음 코드로 런타임에서 감지할 수 있다.
// wasm-feature-detect 없이 최소 체크
async function hasWasmSimd() {
try {
// SIMD 명령(v128.const)이 든 최소 모듈 바이트
const bytes = new Uint8Array([
0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,
2,1,0,10,10,1,8,0,65,0,253,15,253,98,11
]);
await WebAssembly.instantiate(bytes);
return true;
} catch {
return false;
}
}
hasWasmSimd().then(ok =>
console.log(ok ? 'SIMD 지원 — 빠른 경로' : 'SIMD 미지원 — 성능 폭락 주의')
);
흔한 함정 2: 번들 로딩 전략을 안 짜면 첫 화면이 죽는다
7MB를 초기 번들에 그냥 넣으면 First Contentful Paint가 박살난다. WASM 모듈은 반드시 지연 로딩해야 한다. 검색창에 포커스가 갔을 때, 혹은 사용자가 첫 글자를 입력했을 때 로드를 시작하는 식이다.
let ternlight = null;
async function ensureTernlight() {
if (!ternlight) {
// 동적 import로 초기 번들에서 분리
ternlight = await import('@ternlight/base');
}
return ternlight;
}
// 검색 인풋 포커스 시점에 미리 워밍업
searchInput.addEventListener('focus', () => { ensureTernlight(); }, { once: true });
async function search(query, corpus) {
const { similar } = await ensureTernlight();
return similar(query, corpus, { topK: 5 });
}
추가로 신경 쓸 것들:
- CDN 캐싱: 5~7MB 파일은 immutable 캐시 헤더(
Cache-Control: public, max-age=31536000, immutable)로 내려서 재방문 시 재다운로드를 막아야 한다. 파일명에 해시를 박는 건 기본. - 스트리밍 컴파일: 라이브러리 내부가
WebAssembly.instantiateStreaming을 쓰는지 확인하면 좋다. 이걸 쓰면 다운로드와 컴파일이 동시에 진행돼 체감 로딩이 빨라진다. 이때 서버가Content-Type: application/wasm을 정확히 내려주지 않으면 다음 경고가 뜬다:
Uncaught (in promise) TypeError: WebAssembly.instantiateStreaming failed
because your server does not serve wasm with application/wasm MIME type.
Falling back to ArrayBuffer instantiation.
이건 에러가 아니라 폴백 경고지만, 폴백되면 전체를 메모리에 버퍼링한 뒤 컴파일하므로 느려진다. nginx라면 types { application/wasm wasm; } 한 줄 추가하면 된다.
흔한 함정 3: 검색 품질을 과신하면 안 된다
작은 증류 모델이라 정확도 한계가 분명하다. HN 데모에서 "how to use typescript with createContext"를 검색했더니 상위 결과가 typescript 항목만 나와서 유사도 검색이 실패한 것처럼 보인다는 지적이 있었다. 즉 복합 의도나 긴 쿼리에서는 큰 모델만큼 안 나온다.
실무 대응은 하이브리드 검색이다. HN에서도 언급됐듯, 네이티브 키워드 검색(SQLite FTS5/BM25 등)과 Ternlight의 의미 검색을 Reciprocal Rank Fusion으로 합치는 방식이다. 키워드 매칭이 놓치는 걸 의미 검색이 잡고, 의미 검색이 애매한 걸 키워드가 잡아준다.
언어와 대안
다국어 성능은 확실치 않다. HN에서도 "영어 외 언어를 얼마나 잘 처리하는지" 질문이 나왔고 명확한 답은 없었다. 한국어 서비스라면 반드시 자체 검증이 필요하다. 참고로 함께 소개된 Garu(브라우저에서 도는 1.7MB 한국어 형태소 분석기)처럼 한국어 특화 클라이언트 도구와 조합하는 것도 방법이다.
대안 후보:
- Transformers.js + ONNX(MiniLM/MPNet): 더 무겁지만 성숙하고 모델 선택지가 넓다. HN에서 실제로 클라이언트 측에서 잘 돌았다는 후기가 있다.
- 서버 임베딩 + Pagefind류 정적 검색: 검색이 드물거나 정확도가 중요하면 여전히 유효.
- 정적 벡터 검색(portable-hnsw + HTTP Range 요청): 대규모 코퍼스를 정적 호스팅으로 검색하는 실험적 방향. 필요한 청크만 범위 요청으로 받는다.
4. 정리: 누가 언제 써야 하나
한 줄 요약: Ternlight는 임베딩 서버 없이 브라우저 CPU만으로 시맨틱 검색을 돌리는 7MB(mini 5MB) 라이브러리로, 초기 다운로드 비용을 세션 내 다수 검색으로 상각할 수 있는 서비스에 잘 맞는다.
쓰면 좋은 경우
- 문서 사이트/기술 문서 검색처럼 사용자가 한 세션에서 여러 번 검색하는 서비스
- 프라이버시가 중요해서 입력 텍스트를 서버로 보내면 안 되는 경우(온디바이스가 곧 장점)
- 임베딩 API 비용/요율 제한이 부담스러운 소규모~중규모 검색 기능
- 후보 임베딩을 빌드 타임에 미리 계산해 정적으로 내려줄 수 있는 구조
피해야 하는 경우
- 검색 한 번 하고 이탈하는 랜딩성 페이지(7MB 다운로드가 순손해)
- SIMD 미지원 구형 기기/웹뷰 비중이 높은 사용자층(성능 폭락)
- 복합 의도·긴 쿼리에 대한 높은 검색 정확도가 필수인 경우(큰 모델 또는 하이브리드 필요)
- 한국어 등 비영어 검색 품질을 검증하지 않은 상태의 프로덕션 투입
개인적으로는 "임베딩 서버를 없앨 수 있다"는 선택지 자체가 아키텍처 판단의 폭을 넓혀준다는 게 제일 크다고 본다. 다만 도입 전에 반드시 (1) 타깃 브라우저 SIMD 지원, (2) 7MB 로딩 전략과 CDN 캐싱, (3) 한국어 검색 품질, 이 세 가지는 직접 재보고 결정하자. 데모 페이지 열자마자 팬이 미친 듯이 돌더라는 HN 후기도 괜히 나온 게 아니다 — CPU를 진짜로 쓴다.
참고 자료
- Ternlight, 브라우저(WASM)에서 실행되는 7MB 임베딩 모델 — GeekNews
- Ternlight 데모 (React 문서 2천 개 클라이언트 검색)
- Ternlight 저장소 (기술 세부사항·학습 파이프라인·MIT 라이선스)
- Pagefind — 정적 사이트용 검색(비교 참고)
- portable-hnsw — 정적 Parquet + HTTP Range 벡터 검색
※ 본문 중 mini 버전 내부 구조(256→384 투영)와 SIMD 폴백 관련 성능 수치는 HN 커뮤니티 관찰·저자 답변에 기반한 것으로, 정확한 사양은 공식 저장소 문서 확인이 필요하다.