본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 21. 21:38

왜 단순한 문자열 매칭이 로컬 RAG를 위한 Apple의 NLEmbedding을 이겼는가

요약

Apple의 NLEmbedding을 활용한 로컬 RAG 시스템 구축 과정에서 발생한 검색 성능 문제를 다룹니다. 임베딩 모델의 낮은 유사도 점수와 무관한 파일이 더 높은 점수를 받는 현상을 분석하며, 단순 문자열 매칭의 필요성을 시사합니다.

핵심 포인트

  • Apple NLEmbedding의 낮은 코사인 유사도 정확도 문제
  • 임베딩 모델이 무관한 파일(.ds_store)에 더 높은 점수를 부여하는 현상
  • 언어 불일치 및 영어 쿼리 사용 시에도 발생하는 성능 저하 문제
  • 로컬 RAG 구현 시 임베딩 기반 검색의 한계와 하이브리드 방식의 필요성

Apple의 NLEmbedding이 나를 어떻게 미치게 만들었는지, 그리고 내가 어떻게 나만의 하이브리드 검색 엔진을 구축했는지에 대하여

최근 나의 개인용 AI 에이전트(pheronagent)를 작업하면서, 나는 에이전트의 메모리 및 검색 (retrieval) 시스템을 완벽하게 만드는 데 집중하고 있었다.

모두가 그 유명한 약어에 대해 이야기하고 있다: RAG (Retrieval-Augmented Generation, 검색 증강 생성).

시스템은 간단하다: 에이전트에 내 문서들을 입력하면, 에이전트는 이를 벡터 (embeddings, 임베딩)로 변환하고, 내가 질문을 던지면 가장 유사한 벡터를 찾아 답변을 해준다. 이론상으로는 완벽해 보이지 않는가?

그래서 충성스러운 Apple 생태계 개발자답게, 외부 소스에서 거대한 모델을 다운로드하거나 (또는 API 비용을 낭비하는 대신), 기기 내에서 직접 실행되는 운영 체제의 네이티브 기능인 NLEmbedding을 사용하기로 결정했다. 결국 Apple이 이를 OS에 내장해 두었으니, 빠르고 개인정보 보호에도 중점을 둔 방식이었다.

하지만 실제 상황은 WWDC 발표처럼 매끄럽게 진행되지 않았다...

내가 어디서 일했나? - 첫 번째 폭발

모든 것은 아주 순수한 질문 하나에서 시작되었다. 나는 시스템에 내 이력서(CV)를 업로드해 두었다. 에이전트와 대화하던 중, 나는 무심코 물었다:

"내가 어디서 일했지?"

나는 에이전트가 몇 초 안에 백그라운드에서 Metal 코어를 가동하여 내 이력서를 찾아내고, 회사 목록을 나열해 줄 것이라 기대했다. 하지만 에이전트는 멍하니 서 있을 뿐이었다. 나는 검색 엔진이 백그라운드에서 도대체 무엇을 하고 있는지 확인하기 위해 로그를 열었다. 충격적인 시나리오는 바로 이것이었다:

  • 쿼리와 내 실제 이력서 텍스트 사이의 코사인 유사도 (cosine similarity): 0.587
  • 내가 설정한 관련성 임계값 (threshold): 0.60

아주 근소한 차이로 놓친 것이다! "걱정 마," 나는 생각했다. "임계값을 0.55 정도로 조금만 낮추면 해결될 거야."

하지만 바로 그 다음 줄에서 정말 무서운 것을 발견했다. 정확히 동일한 쿼리에 대해, 시스템 내의 완전히 무관한 쓰레기 기록인 .ds_store를 포함하는 파일 목록이 받은 점수는 얼마였을까? 바로 0.59 - 0.60이었다!

잠깐만요... 제 상세하고 여러 페이지에 달하는 이력서가 단지 "which", "company", "work"라는 단어가 정확히 그 순서대로 포함되어 있지 않다는 이유만으로 0.587점을 받았다고요? 반면에 디스크 구석에서 긁어모은 의미 없는 숨겨진 파일 목록이 제 이력서보다 더 높은 점수를 받았다니!

"언어 불일치 때문일 거야"라는 오류

저는 즉시 가설을 세우기 시작했습니다. Apple의 nlembedding.sentenceembedding(for: .english) 모델은 이름에서 알 수 있듯이 영어에 최적화되어 있었습니다. 제가 터키어로 질문했기 때문에, 모델은 아마도 단어들을 "어휘 외 (Out of Vocabulary, OOV)"로 태깅하고 벡터 공간(Vector Space)의 완전히 무작위적인 지점으로 던져버렸을 것입니다. .ds_store 목록의 높은 점수는 단지 이러한 무작위성의 산물이었을 뿐입니다. 순전히 운 좋게 유사한 벡터 근처에 착륙했을 뿐이죠.

"좋아," 저는 말했습니다. "모델이 영어라면, 나도 영어로 물어보겠어. 어쨌든 AI는 모든 언어를 구사하니까."

저는 프롬프트를 변경했습니다: "which companies have i worked at?" (내가 어떤 회사들에서 일했었지?)

저는 기대감을 가지고 로그를 지켜보았습니다. 제 예상은 영어 모델이 자신의 모국어로 된 이 쿼리를 완벽하게 이해하여 제 이력서의 점수를 0.80 근처까지 끌어올려 줄 것이라는 점이었습니다.

결과는요? 0.17이었습니다.

네, 제대로 읽으신 게 맞습니다. 0.17입니다. 영어로 질문했더니 점수가 훨씬 더 폭락했습니다. 저의 언어 호환성 이론은 눈앞에서 카드 집처럼 무너져 내렸습니다.

Apple의 NLEmbedding 내부에는 무엇이 들어있을까?

이 참사 이후, 저는 조사를 해보기로 했습니다. Apple의 NLEmbedding 클래스는 실제로 내부에서 어떻게 작동할까요?

저는 Apple 기기에서의 NLEmbedding(특히 이전 iOS/macOS 버전에서 상속된 구조들)이 BERT나 GPT와 같은 거대하고 동적인 트랜스포머 기반 모델(Transformer-based models)처럼 작동하지 않는다는 것을 알게 되었습니다. 이 모델은 GloVe (Global Vectors for Word Representation)와 같은 정적 단어 벡터 표현(Static word vector representations)이나, 단어 수준의 압축에 기반한 매우 경량화된 신경망 아키텍처(Neural network architectures)에 의존할 가능성이 매우 높습니다.

이러한 모델의 가장 큰 약점은 문맥 이해(Contextual understanding)가 극도로 제한적이라는 점입니다. 즉:

  • 이들은 "i went to the bank to deposit money(돈을 입금하러 은행에 갔다)"에서의 "bank"와 "i sat on a wooden bank by the river(강가의 나무 둑에 앉았다)\

사실, 이 Metal 커널의 이야기는 훨씬 더 비극적이었습니다. 이 글을 쓰기 얼마 전, 저는 이 커널이 그 어떤 환경에서도 실행되지 않고 있었다는 사실을 발견했습니다. CLI 테스트에서도, 별도의 XPC 서비스에서도, 심지어 실제 .app 번들 내부에서도 말이죠. 그 이유는 순전히 SwiftPM의 함정이었습니다. device.makeDefaultLibrary() 호출은 Bundle.main의 최상위 리소스 폴더에 있는 컴파일된 Metal 라이브러리만 찾습니다. 하지만 SwiftPM은 패키지 타겟의 .metal 파일들을 자체적인 중첩된 별도 리소스 번들(pheronagent_pheronagentcore.bundle)에 포함시키는데, makeDefaultLibrary()는 이곳을 전혀 확인하지 않습니다. 이는 이 영리한 GPU 코드가 몇 달 동안 그곳에 머물면서, 매번 조용히 nil을 반환하며 백그라운드에서 아무런 계산도 수행하지 않은 채 그냥 지나쳐 왔음을 의미했습니다. 해결책은 그만큼이나 우아했습니다. 커널을 리소스 파일에서 컴파일하는 대신, 런타임에 Swift에 임베드된 문자열로부터 device.makeLibrary(source:options:)를 사용하여 직접 컴파일하는 것이었습니다. 번들 의존성도 없고, 어떤 프로세스에서 실행되는지에도 완전히 무관하게 작동합니다.

그 문제를 해결하자 커널이 실제로 작동하기 시작했습니다. 하지만 곧 보게 되겠지만, 이는 빙산의 일각에 불과했습니다.

컴퓨터 과학의 가장 오래된 규칙이 다시 한번 제 얼굴을 강타했습니다: Garbage In, Garbage Out (쓰레기가 들어가면 쓰레기가 나온다). 아무리 Metal을 사용하여 빠르게 계산한다 해도, Apple의 NLEmbedding에서 오는 벡터들이 무의미하다면 아무런 소용이 없습니다.

쓰라린 진실: Apple의 모델은 판별적(Discriminative)이지 않습니다

그 순간, 저는 Apple의 온디바이스(on-device) NLEmbedding 모델이 저의 작고 개인적이며 노이즈가 많은 데이터셋에 대해 실질적인 판별력(discriminative power)을 갖추지 못했다는 것을 명확히 알게 되었습니다. 관련 있는 콘텐츠와 완전히 무관한 콘텐츠가 모두 0.50에서 0.60 사이의 어딘가에 밀집되어 있었습니다. 모델은 텍스트의 일반적인 "의미론적 지도(semantic map)"를 매핑하고 있었지만, 구체적인 질문에 답할 수 있을 만큼 미세 조정(fine-tuned)되어 있지는 않았습니다.

임계값(threshold)을 조절하는 것만으로는 이 문제를 해결할 수 없었습니다. 임계값을 0.5로 낮추면 쓰레기 파일들이 쏟아져 나왔고, 0.7로 높이면 시스템은 아무것도 찾지 못하는 눈먼 로봇이 되어버렸습니다. 그것은 순전히 운에 맡기는 게임이 되어버렸습니다.

오늘 저는 에이전트의 메모리 시스템에 많은 수정 사항을 적용했습니다. 콘텐츠 기반 임베딩(content-based embedding)으로 전환하고, 903개의 과거 기록을 인내심 있게 다시 임베딩(re-embedding)하며, 채팅 모드에서 임계값 트리거 검색(threshold-triggered searches)을 설정하고, 시스템 프롬프트(system prompts)를 정교화했습니다. 이 모든 것들은 올바르고 논리적이며 아키텍처적으로 필요한 단계들이었습니다. 하지만 사슬은 가장 약한 고리만큼만 강한 법입니다. 그리고 저의 가장 약한 고리는 이 화려한 아키텍처 전체가 의존하고 있는 근간인 유사성 엔진(similarity engine)이었습니다.

저는 신뢰할 수 없는 토대 위에 구조물을 세우고 있었습니다. 이 유사성 엔진을 고치지 않는다면, 그 CV(Computer Vision) 시나리오나 그 어떤 개인 데이터 어시스턴트 시나리오도 안정적으로 작동할 수 없을 것입니다.

갈림길: 새로운 모델인가, 새로운 지능인가?

저는 두 가지 선택지에 직면했습니다:

  1. 강력한 수단 동원하기: Apple의 장난감 같은 NLEmbedding을 쓰레기통에 던져버리고, MLX(Apple Silicon의 머신러닝 프레임워크)를 통해 Hugging Face의 전체 모델(all-MiniLM-L6-v2 또는 다국어 모델 등)을 실행하는 것입니다.

    • 단점: 사용자가 앱을 시작할 때 수백 메가바이트의 추가 모델 가중치(model weights)가 RAM에 로드될 때까지 기다려야 합니다. 배터리 소모가 급증할 것입니다. 동작이 느려질 것입니다. 이는

문제의 근본 원인은 이것이었습니다: "turgay", "cv", 또는 "apple"과 같은 단어들은 고유 명사(proper nouns)이거나 구체적인 사실(concrete facts)입니다. 임베딩 모델(embedding model)은 이러한 의미들을 "사람", "문서", 또는 "회사"와 같이 일반화합니다. 하지만 제가 검색할 때는 어떤 일반적인 회사를 찾는 것이 아니라, 제 자신의 cv에 있는 회사들을 찾고 있는 것입니다. 이 경우, 의미론적 유사성(semantic similarity)보다 문자 그대로의(exact) 일치(match)가 훨씬 더 가치 있었습니다.

그렇다면 두 가지를 모두 결합하면 안 될까요?

계획은 단순하지만 치명적이었습니다:

  1. 레코드(records)는 평소처럼 Metal 상에서 코사인 유사도(cosine similarity)를 통해 점수가 매겨집니다 (그 형편없는 0.587 점수는 그대로 유지합니다).
  2. 그다음, 사용자의 쿼리(query)를 단어들("which", "company", "work", "cv")로 분리합니다.
  3. 이 단어들이 레코드 텍스트에 문자 그대로 나타나는지 확인합니다.
  4. 의미 있는 단어가 일치할 때마다, 해당 레코드의 점수에 작은 "보너스(bonus)"를 추가합니다!

저는 이렇게 생각했습니다:
만약 쿼리와 레코드 텍스트 사이에 문자 그대로의 단어 또는 이름 일치가 있다면 (예를 들어, "turgay"나 "cv"가 양쪽 모두에 나타난다면), 이를 임베딩 점수에 더하자.

이것은 특히 고유 명사나 구체적인 사실을 포함하는 개인 데이터에 있어 믿을 수 없을 정도로 우아한 해결책이었습니다. 훨씬 더 신뢰할 수 있고, 몇 초 만에 코드로 구현 가능하며, 무엇보다도 추가적인 무거운 AI 모델이 필요하지 않았습니다.

불용어(stop-word)의 위협과 짧은 단어의 함정

코딩을 시작했을 때 가장 먼저 떠오른 함정은 악명 높은 터키어 대소문자 문제였습니다. 로케일(locale)을 고려한 lowercased() 호출 없이는 i/i/i/i 문자 쌍이 쉽게 불일치할 수 있습니다. 솔직히 말해서, 첫 번째 버전에서는 이를 건너뛰고 일반적인 lowercased()를 사용했습니다. 쿼리는 자유 형식의 사용자 입력이었고 단어들은 contains()를 사용하여 검색되었기 때문에 실제로는 문제가 되지 않았습니다. (스스로에게 남기는 메모: 이것은 실제 기술 부채(tech debt)입니다. 언젠가 "istanbul"이 "istanbul"과 일치하지 않게 되는 날, 이 문제가 다시 저를 괴롭힐 것입니다.)

제가 진지하게 고려한 두 번째 함정은 불용어 (stop-words)와 짧거나 의미 없는 토큰 (tokens)이었습니다. 쿼리(query)에 포함된 "and", "of", "which", "what", "a"와 같은 단어들은 거의 모든 문서에 등장합니다. 만약 이러한 단어들에 가산점을 준다면, 그 .ds_store 파일이 다시 검색 결과 최상단으로 올라와 검색 결과를 오염시킬 것입니다. 마찬가지로, 문장 부호 파싱 (punctuation parsing) 과정에서 남겨진 1~2글자의 단어 파편들도 노이즈를 생성하고 있었습니다.

저는 에이전트가 두 언어 모두에서 작동하기 때문에 터키어와 영어를 모두 지원하는 2단계 필터를 설정했습니다:

private static let stopwords: Set<String> = [
    "the", "a", "an", "is", "are", "was", "were", "do", "does", "did", "i", "you", "me",
    "my", "have", "has", "had", "what", "which", "who", "where", "when", "how",
...

count > 2 필터는 모든 짧은 접미사나 약어를 불용어 (stopword) 집합에 명시적으로 나열할 필요 없이, 의미 없는 1~2글자의 파편들을 자동으로 걸러냅니다. 따라서 사용자가 "which companies have i worked at"라고 질문하면, 시스템은 "companies"와 "worked"만을 추출하여 해당 매칭에 가산점을 부여합니다.

하이브리드 검색 (hybrid search)에서의 수학적 가중치 부여

이제 가장 만족스러운 부분인 공식화 (formulation) 단계입니다.

단순히 원시 점수 (raw points)를 무작정 더하는 대신, 저는 단어 매칭의 영향력을 제어하고 싶었습니다. 매우 긴 문서에서 우연히 나타나는 단어는 간결하고 집중된 문서에 나타나는 단어와 동일한 가중치를 가져서는 안 됩니다. 또한, 추가된 가산점이 코사인 유사도 (cosine similarity)를 완전히 압도하여 시스템을 단순한 키워드 검색 도구로 전락시켜서도 안 됩니다. 의미론적 지능 (semantic intelligence)이 여전히 비중을 가져야 했습니다.

저는 다음과 같은 공식을 고안했습니다:

최종 점수 (final score) = w * 의미론적 점수 (semantic score) + (1 - w) * 키워드 점수 (keyword score)

저는 시행착오를 통해 최적의 가중치 (w) 파라미터를 찾기 위해 실험을 진행했습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0