
벡터 데이터베이스가 필요하지 않은 이유
요약
보안과 오프라인 작동을 위해 서버 없이 기기 내부에서 실행되는 온디바이스 RAG 구현 방법을 설명합니다. 소규모 데이터셋에서는 복잡한 벡터 데이터베이스 대신 행렬 연산과 내적을 활용한 직접적인 구현이 효율적임을 강조합니다.
핵심 포인트
- 보안을 위해 서버와 API 키 없이 온디바이스에서 RAG 구현 가능
- flutter_gemma와 ai_edge_rag를 활용한 소형 모델 실행
- 소규모 데이터는 벡터 DB 대신 행렬과 내적 연산으로 충분히 처리 가능
- 데이터 구축(임베딩)과 답변(검색) 단계의 분리 및 최적화
사용자 자신의 노트에 대한 질문에 답변하는 Flutter 앱을 원했지만, 그 어떤 내용도 휴대폰 밖으로 나가지 않아야 했습니다. 서버도, API 키도, 문서 업로드도 없습니다. 비행기 안이나 보안이 강화된 업무용 노트북에서도 작동해야 합니다.
풀스택 패키지들이 이를 가능하게 해줍니다. flutter_gemma는 Gemma와 같은 소형 모델을 온디바이스(on-device)에서 실행하며, ai_edge_rag는 Google의 MediaPipe 온디바이스 스택을 기반으로 구축되었습니다. 만약 오늘 오후에 온디바이스 검색 기능이 작동하는 것을 원한다면, 이 중 하나를 사용하고 읽는 것을 멈추세요.
저는 'RAG'가 추상화(abstraction)를 제거했을 때 실제로 무엇인지 알고 싶어서 계속 글을 썼습니다. 휴대폰에서는 밀리초와 메가바이트 각각을 직접 계산해야 하며, 둘 다 숨기는 블랙박스는 결코 감당할 수 없는 것입니다. 그래서 저는 작은 Dart 조각들로 파이프라인을 구축하고 각 단계를 관찰했습니다.
시작하기 전에 솔직히 밝힙니다: 제가 아래에서 의존하는 세 가지 패키지를 직접 작성했습니다. 이 아이디어들은 그 패키지들에 의존하지 않으며, 마지막에 풀스택 대안들을 다시 언급하겠습니다.
RAG가 실제로 무엇인지
검색 증강 생성(Retrieval-augmented generation)은 두 개의 동사로 이루어집니다. 사용자의 질문과 가장 관련성이 높은 몇 가지 텍스트 조각을 검색(Retrieve)한 다음, 그 조각들을 프롬프트에 붙여넣어 답변을 생성(Generate)하는 것입니다. 모델은 일반적인 상태를 유지하고 지식은 우리가 검색한 내용이 됩니다.
모든 튜토리얼이 찾는 '벡터 데이터베이스'는 단일 장치에서 하나의 앱이 보유하는 규모에서는 단지 행렬(matrix)과 내적(dot product)에 불과합니다. 천 개의 노트가 데이터베이스 문제가 아닙니다. 그것은 산술 문제이며, Dart는 산술 계산에 능숙합니다.
단계에 들어가기 전, 단 한 번의 분할(split)이 중요합니다. 인덱스(index)는 한 번 구축하면, 그 인덱스를 대상으로 모든 질문에 답할 수 있습니다. 구축(Building)이란 문서를 청크(chunk)로 나누고 임베딩(embed)하는 것을 의미합니다. 답변(Answering)이란 질문을 임베딩하고 검색하는 것을 의미합니다. 답변 경로는 기기 외부로 아무것도 나가지 않은 채 휴대폰 내에서 실행되어야 하며, 이는 순수 Dart로 이루어집니다. 구축은 편리한 곳 어디에서나 실행할 수 있습니다. 처음 실행 시 휴대폰에서 실행하거나, 코퍼스(corpus)가 고정되었을 때 자신의 컴퓨터에서 실행한 뒤 인덱스를 앱과 함께 배포할 수 있습니다.
1단계: 의미 단위로 청크 나누기, 토큰(token) 단위로 예산 관리하기
문서 전체를 하나의 벡터(vector)로 임베딩하여 유용한 매칭을 기대할 수는 없으며, 문서 전체를 프롬프트(prompt)에 넣을 수도 없습니다. 그래서 분할(split)을 합니다. 단순한(naive) 분할 방식은 글자 수(character count) 기준인데, 이는 단어와 문장을 무참히 반으로 잘라버립니다. 문단 경계(paragraph boundaries)를 기준으로 분할하면 각 청크가 하나의 아이디어를 유지할 수 있습니다.
final chunker = Chunker.paragraphs(maxChars: 800);
하지만 글자 수는 대략적인 척도일 뿐입니다. 모델이 강제하는 예산은 토큰(token)이며, 실제 개수는 모델 자체의 토크나이저(tokenizer)만이 알고 있습니다. "The quick brown fox"는 네 개의 단어이지만, 토크나이저마다 토큰 수는 달라집니다. 따라서 정직한 방식은 먼저 문단 단위로 분할한 다음, 실제 토큰 수가 예산을 초과하는 청크를 다시 분할하는 것입니다.
final tok = Tokenizer.fromFile('assets/tokenizer.json');
for (final c in chunker.chunk(doc)) {
if (tok.encode(c.text).length > maxTokens) {
...
이 정확한 개수는 hf_tokenizers에서 가져오는데, 이는 FFI를 통해 Rust의 tokenizers 크레이트(crate)를 래핑(wrap)하며 모델과 함께 제공되는 동일한 tokenizer.json을 로드합니다. 한 가지 주의사항을 솔직하게 말씀드리자면, 이는 현재 데스크톱과 서버 Dart에서 실행되는 네이티브 패키지이며, 아직 Android나 iOS에서는 실행되지 않습니다. 이는 인덱스 구축이 보통 이루어지는 환경과 일치합니다. 어차피 인덱스는 휴대폰 외부에서 준비하는 경우가 많기 때문입니다. 만약 반드시 휴대폰 자체에서 인덱스를 구축해야 한다면, 문단 단위로 청크를 나누고 임베더(embedder) 자체의 토크나이저가 예산을 관리하도록 맡기십시오.
2단계: 임베딩(embeddings)이 텍스트를 기하학(geometry)으로 변환하기
임베딩 (embedding)은 텍스트 조각을 숫자 벡터 (vector)로 변환하는 함수로, 의미가 유사한 텍스트가 공간상에서 서로 가깝게 위치하도록 배치합니다. "annual leave"와 "vacation days"는 공유하는 철자가 거의 없지만, 거의 동일한 벡터로 출력됩니다. 이러한 근접성이 검색 (retrieval)의 핵심 비결입니다.

제 파이프라인(pipeline) 내에서 임베딩을 생성하는 것은 아무것도 없습니다. 그것은 가장 무거운 부분이며 모델에 속해 있습니다. 기기 내의 MediaPipe Text Embedder이든, 여러분이 이미 실행 중인 무엇이든 말이죠. 휴대폰에서 임베딩 생성기 (embedder)는 모델 자체를 제외하고 상주하는 가장 큰 요소이므로, 그 크기와 속도가 여기 있는 어떤 코드보다 여러분의 실제 예산을 결정합니다. rag_kit은 문자열 배치를 벡터로 변환하는 단일 함수를 가져가며, 여러분이 이를 어떻게 생성하는지에 대해서는 관여하지 않습니다.
final retriever = Retriever(
embedder: myEmbedder, // 문자열 배치 입력, 벡터 출력
store: InMemoryVectorStore(),
...
임베딩 생성기를 전달받는 일반 함수로 유지하면, 테스트 시 가짜(fake)를 전달하여 모델 없이도 전체 파이프라인을 실행해 볼 수 있습니다.
3단계: 검색은 내적 (dot product)이다
질문에 가장 가까운 청크 (chunks)를 찾으려면, 질문의 벡터를 저장된 모든 벡터와 비교하여 가장 가까운 몇 개를 유지합니다. 표준 측정 방식은 두 벡터 사이의 각도의 코사인 값인 코사인 유사도 (cosine similarity)입니다. 같은 방향이면 1에 가까운 점수를 얻고, 관련이 없으면 0에 가까운 점수를 얻습니다. 이는 길이는 무시하고 방향만을 고려하며, 방향이 곧 의미인 경우에는 이 방식이 적절합니다.
두 정규화된 벡터(normalized vectors) 사이의 코사인(Cosine) 유사도는 단순히 두 벡터의 내적(dot product)이며, 내적은 두 리스트를 따라 곱하고 더하는 과정입니다. 가장 명백한 구현 방식은 이중 루프(double loop)입니다. 이 방식은 작동은 하지만, 매 행(row)마다 하드웨어의 효율성을 저해하기 때문에 당신이 작성할 코드 중 가장 느린 코드가 될 것입니다.
해결책은 더 영리한 알고리즘이 아닙니다. 바로 메모리 레이아웃(memory layout)입니다. 만약 각 벡터를 개별적인 List<double>로 저장한다면, CPU는 매 행마다 새로운 힙(heap) 객체를 가리키는 포인터를 쫓아가야 하며, 각 컴포넌트당 8바이트를 읽어야 합니다. 반면, 모든 벡터를 하나의 연속적인(contiguous) Float32List에 끝과 끝을 맞물려 채워 넣는다면, 전체 코퍼스(corpus)는 CPU가 스트리밍할 수 있는 하나의 평탄한 버퍼(flat buffer)가 됩니다. 이 경우 각 컴포넌트당 4바이트를 사용하며, Dart의 SIMD Float32x4 타입을 통해 한 번에 4개의 레인(lane)을 읽을 수 있습니다.
이것이 느린 버전과 빠른 버전의 차이이며, vector_kit이 VectorMatrix 내부에서 수행하는 방식입니다. 이 라이브러리는 행이 추가될 때 각 행의 길이를 캐싱(cache)하므로, topKCosine은 쿼리 시점에 수동으로 정규화할 필요가 없습니다.
final index = VectorMatrix(384);
for (final v in chunkVectors) {
index.add(v); // packed in, L2 norm cached
...
384차원의 2만 개 청크(chunk)로 구성된 데모 코퍼스에서, 동일한 데이터에 대해 정직하게 직접 작성한 코사인 루프가 15.9ms가 걸리는 동안 top-5 쿼리는 1.4ms 만에 완료됩니다. 오직 레이아웃의 차이와, 2만 개의 점수를 모두 정렬하는 대신 유계 최소 힙(bounded min-heap)을 사용한 것만으로 11배의 성능 차이가 난 것입니다. 이 수치를 맹신하기 전에 당신의 하드웨어에서 직접 측정해 보세요. 절대적인 수치는 장치에 따라 달라집니다.
4단계: 당신이 계산하는 것을 잊어버린 메가바이트들
사람들은 속도(Speed)를 벤치마크하지만, 실제로 휴대폰에서 앱을 죽게 만드는 것은 메모리(Memory)입니다. 384차원의 벡터 20,000개가 각각 4바이트라면, 29.3 MB가 상주(resident)하게 되며, 이는 이미 휴대폰이 제공하는 RAM의 대부분을 요구하는 온디바이스 언어 모델(on-device language model) 바로 옆에 자리 잡게 됩니다. 하나의 주소 공간(address space)에 두 개의 배고픈 존재가 있는 상황은 바로 저메모리 킬러(low-memory killer)를 유발하는 방식입니다.
해결의 지렛대는 정밀도(precision)입니다. 각 임베딩(embedding) 구성 요소는 전체 32비트 부동 소수점(32-bit float)이 필요하지 않은 작은 숫자입니다. 벡터당 하나의 스케일(scale) 값을 저장하되, 가장 큰 절대값 구성 요소를 127로 나눈 값을 사용하고, 모든 구성 요소를 이를 기준으로 부호 있는 바이트(signed byte)로 양자화(quantize)하십시오. 순위(Rank order)는 유지됩니다. 부호 있는 바이트의 255개 단계는 순서를 보존하기에 충분하기 때문입니다. 동일한 인덱스가 데모 데이터에서 완전한 재현율(full recall)을 유지하면서 29.3 MB에서 7.6 MB로, 즉 거의 4분의 1 수준으로 줄어듭니다.
final quantized = QuantizedMatrix.from(index);
final hits = quantized.topKCosine(questionVector, 5);
세상에 공짜는 없습니다. 양자화(Quantization)는 반올림 오차(rounding error)를 발생시키며, 일부 데이터에서는 순위(ranking)를 미세하게 변화시킬 수 있습니다. 하나의 데모 코퍼스(corpus)에서 완전한 재현율(full recall)을 보였다고 해서 당신의 데이터에서도 그러리라는 보장은 없으므로, 배포하기 전에 부동 소수점(float) 버전과 비교하여 재현율을 측정해야 합니다. 하지만 상주 메모리(resident memory)를 거의 4배 가까이 절감하는 것은, 모델과 함께 실행될 수 있는 기능인지 아니면 실행되지 못할 기능인지를 결정짓는 차이가 되곤 합니다.
종합하기
구성 요소들이 준비되면 파이프라인은 짧습니다. rag_kit은 청킹(chunking), 당신의 임베더(embedder), 저장소(store), 그리고 컨텍스트 구축(context building)을 하나의 객체로 연결합니다.
await retriever.addText(handbook, sourceId: 'handbook');
final context = await retriever.buildContext(
...
입력 시에는 청킹(Chunk)하고 임베딩(embed)하며, 출력 시에는 검색(search)하고 붙여넣습니다(paste). 쿼리(query) 시점에는 네트워크 호출이 발생하지 않으며, 사용자의 노트는 장치를 절대 떠나지 않습니다.
기본 요소(primitives)로 구축해야 할 때와 그렇지 않을 때
오늘 바로 온디바이스 RAG (on-device RAG)를 작동시키고 싶고, 전체 스택을 주어진 그대로 사용하는 것에 만족한다면, flutter_gemma 또는 ai_edge_rag를 사용하세요. 이들은 모델, 임베더 (embedder), 그리고 저장소 (store)를 함께 제공하며, 이는 대개의 경우 올바른 선택입니다.
특정 단계를 직접 제어해야 할 때는 기본 요소 (primitives)부터 구축해야 합니다. 예를 들어, 자신만의 임베딩 모델 (embedding model), 자신만의 메모리 예산 (memory budget), 또는 문서의 형태에 따른 자신만의 청킹 규칙 (chunking rules)이 필요한 경우입니다. 또는 당신이 배포한 것이 무엇인지 정확히 이해하고 싶을 때도 마찬가지입니다. 왜냐하면 디바이스 환경에서는 시간과 RAM 소모에 대한 책임이 당신에게 있으며, 내부를 들여다볼 수 없는 상자(블랙박스)에 대해서는 책임을 질 수 없기 때문입니다.
한계점 (ceiling) 또한 알고 있어야 합니다. 꽉 찬 행렬 (packed matrix)에 대한 브루트 포스 (Brute force) 방식은 대략 10만 개의 벡터까지는 정답입니다. 그 수치를 넘어서면 HNSW와 같은 실제 근사 최근접 이웃 (approximate-nearest-neighbor) 인덱스와 그에 따른 다른 트레이드오프 (tradeoffs)가 필요합니다. 하지만 한 명의 사용자 데이터를 보유하는 휴대폰 환경에서는 그 한계치를 넘어서는 경우가 거의 없습니다.
따라서 설치할 벡터 데이터베이스는 없었습니다. 단일 디바이스 규모에서, 모두가 인프라로 취급하는 검색 (retrieval)은 행렬 (matrix)과 내적 (dot product)이며, 이는 사용자의 노트가 휴대폰을 절대 떠나지 않은 채 순수 Dart로 실행됩니다. 나머지 부분은 청커 (chunker)와 기존의 임베더 (embedder)가 담당합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

