3단계 온디바이스(On-device) AI 컨시어지 구축하기: Gemini Nano -> MiniLM -> 키워드, 쿼리당 비용 $0
요약
비용과 개인정보 보호 문제를 해결하기 위해 브라우저 내에서 실행되는 3단계 온디바이스 AI 컨시어지 구축 방법을 소개합니다. Gemini Nano, MiniLM, 키워드 매칭을 활용한 단계별 폴백 전략을 통해 안정적인 서비스를 구현합니다.
핵심 포인트
- Gemini Nano를 활용한 브라우저 내장 모델 사용
- Transformers.js 기반 MiniLM을 통한 시맨틱 검색 폴백
- 최후의 수단으로 키워드 매칭 방식 적용
- 사용자 경험을 고려한 지연 로딩 및 모델 다운로드 제어
대부분의 "AI 채팅 위젯" 튜토리얼은 사용자가 LLM API를 호출한다고 가정합니다. 이 포스트는 그렇게 하지 않는 버전, 즉 모델이 방문자의 브라우저 자체에서 실행되어 대화당 비용이 정확히 0원이며, 누군가가 질문하는 내용 중 그 어떤 것도 기기를 떠나지 않는 버전에 관한 것입니다.
명백한 접근 방식의 문제점
서버 측 LLM 호출은 구축하기는 간단하지만, 실제 트래픽이 발생하는 마케팅 사이트에서 대규모로 운영하기에는 비용이 많이 들고 느리며 개인정보가 유출될 위험이 있습니다. 소규모 비즈니스 웹사이트의 경우, "모든 방문자의 채팅 질문이 누군가의 서버에 기록된다"는 점 또한 필요 이상으로 좋지 않은 기본 설정입니다.
3단계 폴백 (Fallback)
- Chrome의 내장 Prompt API (Gemini Nano) -- 브라우저가 이를 지원할 때, 이는 사이트 전용의 작은 지식 베이스 (Knowledge Base, KB)를 기반으로 로컬에서 실행되는 실제 대화형 모델입니다.
- Transformers.js를 통한 MiniLM -- Nano를 사용할 수 없을 때, 시맨틱 검색 (Semantic Search)으로 폴백합니다. 쿼리를 임베딩 (Embedding)하고, 미리 임베딩된 KB 청크 (Chunks)와 비교하여 가장 적합한 결과를 반환합니다. 이 역시 100% 온디바이스 (On-device) 방식입니다.
- 키워드 매칭 (Keyword match) -- 두 모델 중 어느 것도 로드되지 않을 때(오래된 브라우저, 비활성화된 플래그 등)의 마지막 폴백입니다. 동일한 KB에 대해 단순한 부분 문자열 (Substring) 또는 키워드 점수 산출 방식을 사용합니다.
모든 단계는 UI에 명시적으로 표시됩니다. 배지에는 실제로 어떤 단계가 답변했는지 문자 그대로 표시됩니다. 정직하게 성능을 낮추는 것이 가짜로 꾸미는 것보다 낫습니다.
채팅을 실제로 열기 전까지는 아무것도 로드되지 않음
누군가 런처를 클릭하기 전까지 위젯 전체는 무거운 짐일 뿐입니다. KB 가져오기, 모델 다운로드, Transformers.js 임포트 (Import) 등 그 어떤 것도 수행하지 않습니다. 이 모든 과정은 첫 오픈 시에만 실행되는 boot() 호출 뒤에 가려져 있습니다:
launch.addEventListener('click', function () {
if (panel.hasAttribute('hidden')) {
panel.removeAttribute('hidden');
...
boot()는 /concierge/kb-data.js 스크립트 태그를 가져온 다음, 1단계를 시도하고, 그다음 2단계를 시도하며, 최후의 수단으로 3단계까지 내려갑니다:
function boot() {
var s = document.createElement('script');
s.src = '/concierge/kb-data.js';
...
1단계: Gemini Nano, 하지만 실제로 준비되었을 때만
Chrome의 온디바이스 모델 API는 'available', 'downloadable', 또는 'unavailable'을 반환할 수 있는 availability() 체크 기능을 제공합니다. 'downloadable'을 '사용 가능'으로 간주하고 다운로드를 시작하는 것은 쉬운 일이지만, 이는 384px 크기의 코너 위젯 안에서 진행 상태 UI도 없이 수 기가바이트(multi-gigabyte) 규모의 모델을 내려받는 작업입니다. 따라서 코드는 이미 준비되어 있는 모델만 사용하도록 작성되었습니다:
var avail = await LM.availability();
if (avail !== 'available') return resolve(false); // 조용한 수 GB 다운로드는 방지
nanoSession = await LM.create({ initialPrompts: [{ role: 'system', content: SYSTEM }] });
Nano가 답변할 때는 단순한 자유 형식 생성(freeform generation)이 아닙니다. 검색 레이어(retrieval layer)가 가장 먼저 찾아낸 정보에 기반하여(RAG 방식), 해당 컨텍스트(context) 내에서 답변하도록 요청됩니다:
var hits = embedFn ? cosineTop(await embedFn(q)) : liteTop(q);
var ctx = hits.map(h => 'Q: ' + h.e.q + '\nA: ' + h.e.a).join('\n\n');
var prompt = 'Knowledge:\n' + ctx + '\n\nVisitor question: ' + q + '\nAnswer as the concierge...';
Nano의 가공되지 않은 출력(raw output) 또한 DOM에 닿기 전에 마크다운/URL 제거기(markdown/URL stripper)를 거칩니다. 자유 형식 모델(free-text model)은 일반적인 채팅 버블에 어울리지 않는 글머리 기호, 굵은 글씨, 링크 등을 기꺼이 내뱉을 수 있으며, 위젯 자체의 규칙은 대화형 답변에서 URL을 직접 노출하지 않는 것입니다.
2단계: 서버와 벡터 DB 없는 실제 벡터 검색
지식 베이스(knowledge base)는 임베딩(embedding)이 완료된 상태로 배포됩니다. 빌드 단계(이 컴포넌트에서는 "Labs' build-kb.mjs"라고 명시함)에서 모든 KB 항목에 대해 MiniLM 임베딩을 미리 계산하고, 이를 base64로 인코딩된 Float32Array 형태로 /public/concierge/kb-data.js에 직접 포함시킵니다. 런타임(runtime) 시에는 다음과 같이 동작합니다:
function decodeVecs() {
var bin = atob(KB.vecsB64), buf = new ArrayBuffer(bin.length), u8 = new Uint8Array(buf);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
...
벡터 데이터베이스도, 검색을 위한 네트워크 호출도 필요 없습니다. 쿼리 자체가 브라우저에서 실시간으로 임베딩되면, 단지 평면 배열(flat array)과 그 위를 순회하는 수동 코사인 유사도(cosine similarity) 루프만 있으면 됩니다.
function cosineTop(qv, k) {
var scores = [];
for (var i = 0; i < KB.entries.length; i++) {
...
임베딩 (embeddings)이 인코딩 시점에 정규화 (normalized)되기 때문에, 해당 내적 (dot product)이 곧 코사인 유사도 (cosine similarity)가 됩니다. 즉, 쿼리 시점에 별도의 정규화 단계가 필요하지 않습니다.
까다로운 부분은 수학이 아니라 버전 드리프트 (version drift)입니다. 빌드 시점의 임베딩 모델과 런타임 (runtime) 모델이 정확히 동일한 버전이어야 합니다. 그렇지 않으면 벡터 공간 (vector space)이 일치하지 않아 모든 유사도 점수가 무의미해집니다. 해결책은 KB 데이터 자체에 버전 고정 (version pin)을 포함시키는 것입니다 (KB.tfVer). 이를 통해 런타임 임포트 (import)가 벡터를 생성한 실제 버전과 동일하도록 잠금 처리됩니다:
var mod = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@' + KB.tfVer);
var p = await mod.pipeline('feature-extraction', KB.model);
코사인 매칭 결과가 약하면 (점수 0.25 미만), 위젯은 신뢰도가 낮은 추측값을 반환하는 대신 3단계 (tier 3)로 넘어갑니다.
3단계: 단순 키워드 스코어링, 다운로드 제로
최하단 단계는 불용어 (stopword)가 필터링된 키워드 스코어러 (keyword scorer)입니다. 모델도, 임베딩도 필요 없으며 모든 브라우저에서 작동합니다:
function liteTop(q, k) {
var toks = q.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(t => t && !STOP[t]);
var scores = KB.entries.map((e, i) => {
...
길고 구체적인 토큰 (>5자)은 짧은 토큰보다 더 높은 점수를 받습니다. 이는 "the"보다 "concierge"에 더 높은 가중치를 두는 투박하지만 효과적인 방법입니다.
막다른 길이 아닌, 옆으로 비껴가기
절대로 일어나서는 안 되는 한 가지는 방문자가 벽에 부딪히는 것입니다. 만약 Nano의 세션 생성이 성공했지만 실제 prompt() 호출 중에 대화 도중 오류가 발생한다면 (Nano는 여전히 실험적인 API이므로 실제로 발생합니다), 위젯은 동일한 요청 과정 중에 스스로를 2단계 또는 3단계로 강등시키며, 에러를 표시하는 대신 검색 (retrieval)을 통해 답변합니다:
catch (e) {
if (tier === 1) {
tier = embedFn ? 2 : 3;
...
보너스: 에이전트 전용 API
사용자 대상의 채팅과 병행하여, 모든 페이지는 document.modelContext.registerTool()을 통해 구조화된 도구들(get_services, get_pricing, start_free_audit, get_contact)을 등록합니다. 이를 통해 해당 페이지를 프로그래밍 방식으로 방문하는 브라우저 AI 에이전트가 정보를 찾기 위해 DOM을 스크래핑(Scraping)할 필요가 없게 만듭니다. 표현 방식은 다르지만 근본적인 아이디어는 동일합니다. 즉, 사이트는 마우스를 사용하는 인간 이외의 존재에게도 읽기 쉬워야 한다는 것입니다.
한 에이전시의 채팅 위젯을 넘어 이것이 중요한 이유
온디바이스 추론(On-device inference)은 이제 단순한 데모를 넘어 실제 제품 서비스에서도 조용히 실행 가능한 수준이 되고 있습니다. 만약 당신의 제품이 프런티어 모델(Frontier model)의 모든 역량을 필요로 하지 않는다면 — 대부분의 고객 지원/FAQ 스타일의 채팅은 그렇지 않습니다 — 서버 호출을 기본값으로 설정하기 전에 온디바이스 경로를 진지하게 고려해 볼 가치가 있습니다. 3단계 계층 구조가 화면 구석에 있는 채팅 위젯을 만들기에는 공학적으로 과해 보일 수 있지만, 각 단계는 개별적으로 보면 진정으로 단순합니다. 이 방식의 가치는 가장 고성능의 계층을 사용할 수 없는 상황에서도 방문자가 막히지 않도록 보장한다는 점에 있습니다.
실제 사례: https://localenhance.com (우측 하단 "Ask AI" 위젯).
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기