당신의 MCP 서버 검색이 형편없는 이유: 랭킹(Ranking), 임베딩(Embeddings), 그리고 각각의 해결책
요약
MCP 서버의 검색 성능을 개선하기 위해 랭킹 알고리즘 수정과 임베딩 모델 도입을 제안합니다. 단순 알파벳 정렬 대신 점수 기반 정렬을 적용하고, Ollama를 활용한 로컬 임베딩을 통해 검색 정확도를 높이는 방법을 다룹니다.
핵심 포인트
- 단순 알파벳 순 정렬 대신 검색 결과에 점수를 매겨 정렬하는 랭킹 로직 적용
- Ollama의 nomic-embed-text를 활용한 로컬 임베딩 도입으로 의미론적 검색 구현
- 문서를 헤딩 기준으로 청크화하고 .npz 파일로 캐싱하여 성능 최적화
- 완전 일치 방식과 임베딩 방식을 병행하여 검색 누락 방지
1부의 끝에서 저는 메모 서버의 검색 기능이 멍청하다고 설명하며 다시 돌아오겠다고 약속했습니다. 동일한 쿼리에 대한 현재 상태는 다음과 같습니다:
search_notes("firewall")
build-your-first-mcp-server-python: Build your first MCP server in Python: give Claude your own notes
caddy-reverse-proxy-docker-compose-ubuntu-26-04: Reverse Proxy Your Containers with Caddy and Docker Compose on Ubuntu 26.04
create-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04
...
상황이 더 나빠졌습니다. 이제 최상단 결과는 이 검색 기능이 형편없다고 불평했던 1부 포스트 그 자체입니다. 이 포스트가 "firewall"을 언급한 이유는 단지 바로 이 출력 결과를 인용했기 때문입니다. 블로그 포스트는 51개까지 늘어났고, 그중 17개에서 방화벽(firewall)을 언급하고 있지만, UFW Firewall Basics는 여전히 목록에 없습니다.
이 포스트는 두 단계에 걸쳐 이 문제를 해결합니다. 여기에는 두 가지 서로 다른 문제가 있으며, 그중 하나만이 사람들이 흔히 찾는 해결책이기 때문입니다. 첫 번째는 랭킹(Ranking) 버그이며, 약 20줄의 코드로 해결할 수 있습니다. 두 번째는 어떤 랭킹으로도 답할 수 없는 쿼리 문제이며, 이를 위해서는 임베딩(Embedding) 모델이 필요합니다.
요약 (TL;DR) 알파벳 순서로 상위 5개를 가져오는 대신, 모든 검색 결과에 점수를 매기고 정렬하세요. 그것만으로도 올바른 포스트가 상단에 배치됩니다. 그다음
ollama pull nomic-embed-text를 사용하여 로컬 임베딩(Local Embeddings)을 추가하세요.##헤딩을 기준으로 청크(Chunk)를 나누고, 경로(path), 수정 시간(mtime), 모델 이름을 키(key)로 하여.npz파일에 캐싱하세요. 문서에는search_document:를, 쿼리에는search_query:접두사를 붙이세요. 이 작업만으로 인덱스가 올바른 포스트를 첫 번째로 배치하는 빈도가 두 배로 늘어났습니다. 인덱스는 첫 검색 시점이 아니라 명령어를 통해 구축하세요. CPU 환경에서 496개의 청크를 처리하는 데 4분이 걸렸기 때문입니다. 그리고 두 가지 검색 방식을 모두 유지하세요. 왜냐하면 두 방식은 서로 다른 지점에서 실패하기 때문입니다. 완전 일치(Exact match) 방식은 하이픈 때문에brute force를 놓치고, 임베딩 방식은 두 단어만으로는 의미를 파악하기에 충분하지 않아 이를 놓칩니다.
시작하기 전에
- Part One에서 완성된 서버. 아래의 모든 내용은 해당
server.py를 수정합니다. - 로컬에서 실행 중인 Ollama. 해당 포스트는 채팅 모델을 위한 GPU 할당에 관한 것이며, 여기에는 적용되지 않습니다. 임베딩 (Embedding) 모델은 크기가 작으므로 이 작업에는 CPU로도 충분합니다.
- 두 가지 의존성 (Dependencies):
uv add ollama numpy
1. 버그는 매칭 (Matching)이 아니라 랭킹 (Ranking)이었다
Part One에서 이미 이를 언급했으며, 진단 내용이 1단계의 전부이므로 인용할 가치가 있습니다:
부분 문자열 매칭 (Substring match)은 실패하지 않았습니다. UFW 포스트를 잘 찾아냈습니다. 문제는 랭킹 (Ranking)이 전혀 이루어지지 않는다는 점입니다.
원래의 루프가 무엇을 하는지 살펴보십시오:
hits = []
for path in _posts():
text = path.read_text(encoding="utf-8")
...
_posts()는 .md 파일과 .mdx 파일을 각각 글로브 (Glob) 패턴으로 가져온 뒤, 정렬된 두 리스트를 연결합니다. 따라서 각 확장자 내에서는 알파벳 순으로 정렬되며, 어떤 이름이든 .mdx 파일은 항상 마지막에 위치하게 됩니다. 루프는 해당 순서대로 항목을 추가하고 5개에서 중단됩니다. 즉, search_notes는
네 가지 신호가 있으며, 각 신호가 담고 있는 의도(intent)의 양에 따라 가중치가 부여됩니다. 제목 일치(title match)는 가장 높은 점수를 받는데, 이는 제목이 게시물이 자신의 주제에 대해 내세우는 가장 강력한 주장(claim)이기 때문입니다. 슬러그(slug)가 그 뒤를 바짝 쫓는데, 이 블로그에서는 제가 슬러그를 직접 작성하며, 슬러그는 게시물의 내용을 나타내기 때문입니다. 태그(tags)는 그 아래에 위치하며, 본문 포함 여부(body count)는 동점자를 가리는 결정적 요소(tiebreaker) 역할을 합니다. 본문 포함 여부는 최대 10개로 제한되어, 긴 게시물이 단순히 길이만으로 승리할 수 없도록 했습니다.
그다음, 랭킹된 조회(ranked lookup)를 별도의 함수로 추출합니다. 2단계(Stage Two)에서는 도구(tool)와는 별개로 이 기능이 필요할 것이기 때문입니다.
def _keyword_hits(query: str, k: int = 5) -> list[tuple[str, str]]:
"""해당 문구를 포함하는 모든 게시물을 (slug, title) 형태로, 가장 적합한 것부터 반환합니다."""
q = query.lower()
...
동일한 쿼리, 동일한 코퍼스(corpus):
ufw-firewall-basics-ubuntu: UFW Firewall Basics on Ubuntu
hardening-ubuntu-desktop: Hardening an Ubuntu Desktop
ssh-connection-refused-port-22-ubuntu: Troubleshooting "ssh: connect to host port 22: Connection refused"
...
UFW 게시물은 230점을 기록하여 2위인 61점을 크게 앞질렀는데, 이는 제목, 슬러그, 태그, 그리고 본문 모두에서 일치했기 때문입니다. 1부(Part One) 결과는 원래 있어야 할 위치인 4위로 떨어졌습니다.
독스트링(docstring)도 변경되었습니다. 1부에서는 "랭킹이 없습니다"라고 명시하며 모델에게 다른 표현으로 반복해서 검색하도록 지시했습니다. 당시에는 그것이 정직한 방법이었으나 지금은 틀린 방법이며, 그대로 두었다면 모델은 더 이상 존재하지 않는 한계를 극복하기 위해 세 번의 호출을 계속 소모했을 것입니다.
만약 마크다운(markdown) 파일이 50개뿐이라면, 여기서 읽기를 멈추셔도 좋습니다. 20줄의 코드, 새로운 의존성(dependencies) 없음, 모델 불필요, 동기화할 것도 없습니다. 이것이 실질적인 해답이며, 저는 나머지 본문의 정당성을 부여하기 위해 아닌 것을 아닌 척하지 않겠습니다.
2. 쿼리 랭킹(query ranking)이 건드리지 못하는 것
이것이 저를 더 깊이 파고들게 만든 지점입니다. 저는 반복적인 SSH 로그인 시도를 차단하는 방법에 대한 게시물을 가지고 있습니다. 사람이 물어보는 방식으로 요청해 보겠습니다:
search_notes("brute force")
'brute force'를 언급하는 게시물이 없습니다.
랭킹이 나쁘게 매겨진 것도 아니고, 9번째 위치에 묻혀 있는 것도 아닙니다. 51개의 게시물 중에서 결과가 0개입니다.
그곳에는 두 가지 별개의 실패가 중첩되어 있습니다. UFW Firewall Basics는 "throttle brute-force knocking"이라고 말하므로, 해당 단어는 코퍼스 (Corpus) 안에 정확히 존재합니다. 하지만 제가 게시물에 있는 하이픈 대신 공백을 입력했기 때문에 일치하지 않는 것입니다. 부분 문자열 매칭 (Substring matching)은 개념을 찾는 것이 아니라 바이트 시퀀스 (Byte sequence)를 찾는 것이며, 이 둘은 서로 다른 두 개의 바이트 시퀀스입니다.
두 번째 실패가 더 중요한 문제입니다. 이 질문에 실제로 답을 주는 게시물은 Fail2ban post이지만, 여기에는 해당 단어가 어떤 철자로도 포함되어 있지 않습니다. "brute"나 "attack"이라는 단어는 전혀 나오지 않습니다. 게시물은 다음과 같이 시작합니다:
공개 SSH 서버는 주소를 할당받자마자 거의 즉시 로그인 실패 기록을 수집하기 시작합니다. 키 전용 인증 (Key-only authentication)은 그러한 추측을 무용지물로 만들지만, 동일한 주소들이 하루 종일 저널 (Journal)을 채우는 것을 막지는 못합니다.
"Failed logins(로그인 실패)", "guesses(추측)", "the same addresses(동일한 주소들)". 이 모든 표현은 "brute force(무차별 대입)"보다 해당 현상을 더 잘 설명하지만, 그중 어느 것도 누군가가 검색창에 입력할 법한 문구는 아닙니다. 저는 해당 용어를 단 한 번도 사용하지 않고 무차별 대입 공격 (Brute force attacks)을 방어하는 방법에 대해 게시물 전체를 작성했습니다.
여기서 랭킹 (Ranking)의 한계가 드러납니다. 랭킹은 이미 확보된 매칭 결과들의 순서를 정하는 것입니다. 당신이 한 번도 쓰지 않은 단어를 위해 매칭 결과를 만들어낼 수는 없습니다.
그리고 여기서 호출하는 주체는 제가 아닙니다. 누군가의 희미한 기억을 검색어로 변환하는 모델이며, 모델은 제 어휘를 추측하지 않습니다. 1부에서는 독스트링 (Docstring)을 통해 모델에게 다양한 표현을 시도하도록 지시함으로써 이 문제를 임시방편으로 해결했지만, 이는 들리는 것만큼이나 효과가 미미합니다. 서버에 필요한 것은 철자가 아닌 의미에 기반하여 매칭되는 검색입니다.
3. 임베딩 (Embedding)이란 실제로 무엇인가
모델은 텍스트 조각을 읽고 고정된 길이의 부동 소수점(float) 리스트를 반환합니다. 그게 전부입니다. nomic-embed-text는 768개의 값을 제공합니다. 유용한 속성은 유사한 내용을 담은 텍스트들이 해당 768차원 공간 내에서 서로 가까운 곳에 위치한다는 점입니다. 따라서 "brute force attack"과 "the same addresses filling the journal"은 공유하는 단어가 전혀 없음에도 불구하고 서로 가까운 곳에 놓이게 됩니다.
"가깝다"는 것은 코사인 유사도 (cosine similarity)를 의미하며, 단위 길이 벡터(unit-length vectors)의 경우 이는 단순히 내적 (dot product)과 같습니다. 즉, 두 리스트의 요소를 하나씩 곱하고 그 결과들을 모두 더하면 -1과 1 사이의 숫자가 나옵니다. 이 숫자가 높을수록 더 관련성이 높다는 뜻입니다. 이것이 검색 알고리즘의 전부입니다.
이 주제를 검색했을 때 나오는 정보들과는 달리, 여러분에게 필요하지 않은 것이 있습니다. 바로 벡터 데이터베이스 (vector database)입니다. Chroma, pgvector, Pinecone, 혹은 그 어떤 종류의 인덱스 구조도 필요 없습니다. 헤딩(heading) 단위로 나눈 51개의 포스트는 496개의 섹션이 되며, 따라서 전체 인덱스는 약 1.5MB 크기의 496x768 float32 배열일 뿐입니다. 쿼리를 이 모든 항목과 비교하는 것은 단 한 번의 행렬 곱셈 (matrix multiply)으로 끝납니다. 모든 것을 스캔하는 것이 더 이상 공짜가 아닌, 문서가 수만 개 단위로 늘어나는 시점이 되어야 진짜 벡터 저장소가 필요해집니다. 그 미만에서는 문제가 생기면 그냥 삭제해 버릴 수 있는 파일 하나면 충분하며, 이는 생각보다 훨씬 큰 이점입니다.
4. Ollama를 이용한 로컬 임베딩 (Embeddings)
ollama pull nomic-embed-text
현재 SDK 호출 방식은 리스트를 입력받아 리스트를 반환하는 embed()입니다:
import ollama
r = ollama.embed(model="nomic-embed-text", input=["hello", "goodbye"])
...
각각 768개의 부동 소수점으로 이루어진 두 개의 벡터가 생성됩니다. 이전 방식인 ollama.embeddings(prompt=...)는 단일 문자열을 사용하는 지원 중단(deprecated)된 버전입니다. 나중에 배치 처리 (batching)가 중요해지므로 embed()를 사용하세요.
이 모델에는 선택 사항이 아닌 두 가지 주의 사항이 있으며, 두 가지 모두 오류 메시지 없이 조용히 실패합니다.
입력값에 접두사 (Prefix)를 붙이세요. nomic-embed-text는 태스크 접두사 (task prefixes)와 함께 학습되었습니다. 문서는 search_document: {text} 형식으로, 쿼리는 search_query: {text} 형식으로 입력해야 합니다. Ollama는 이를 자동으로 추가해주지 않습니다. 접두사를 생략해도 시스템이 깨지지는 않습니다. 벡터도 나오고, 그럴듯한 유사도 점수와 랭킹 결과도 나옵니다. 하지만 추적해야 할 에러 메시지도 나타나지 않습니다.
저는 이 내용을 포기할 뻔했습니다. 접두사(prefix)가 있는 경우와 없는 경우의 두 문서 간 원시 코사인 점수(raw cosine scores)를 비교해 보면, 수치가 거의 변하지 않아 접두사가 그저 근거 없는 속설(folklore)처럼 보일 수 있기 때문입니다. 하지만 그렇지 않습니다. 원시 코사인(Raw cosine)은 측정하기에 잘못된 지표입니다. 모든 점수를 동일하게 이동시키는 변화는 랭킹(ranking)을 바꾸지 않기 때문입니다. 중요한 것은 올바른 게시물이 어디에 착륙하느냐입니다.
그래서 저는 496개의 청크(chunk) 전체에 대해 인덱스를 두 번 구축하고, 정답이 알려진 8개의 질문을 두 인덱스 모두에 실행해 보았습니다:
| 평균 역순위 (Mean reciprocal rank) | 첫 번째 결과가 정답인 경우 | 평균 순위 (Mean rank) | |
|---|---|---|---|
| 접두사 없음 | 0.480 | 8개 중 2개 | 3.6 |
| 접두사 포함 | 0.682 | 8개 중 4개 | 2.4 |
접두사를 붙이면 첫 번째 결과로 정답을 맞히는 질문의 수가 두 배로 늘어납니다. "무언가가 계속해서 내 서버에 로그인을 시도하고 있다"라는 질문은 7위에서 1위로 올라갑니다. 이것은 속설이 아니며, 비용은 f-string 하나면 충분합니다.
어디서 잘리는지(truncates) 파악하세요. Ollama는 이 모델을 2,048 토큰 컨텍스트(context)로 제공합니다. 더 긴 청크를 입력하면 모델은 뒷부분을 버리고 아무런 말도 해주지 않는데, 이는 연속된 두 번째의 '조용한 실패(silent failure)'입니다.
생각만 한다면 ollama show 명령어가 이를 알려줄 것입니다:
$ ollama show nomic-embed-text
Model
architecture nomic-bert
...
마지막 두 숫자를 함께 읽으십시오. 그 사이에 함정이 숨어 있기 때문입니다. 아키텍처(architecture)는 2,048에서 멈추는데, 모델 파일은 이미 8,192를 요구하고 있습니다. 당신이 막 설정하려고 했던 옵션은 이미 설정되어 있지만, 아무런 역할도 하지 않고 있습니다.
nomic의 자체 모델 카드(model card)가 8,192 윈도우(window)를 설명하고 있으므로, 당연한 조치는 options={"num_ctx": 8192}를 사용하는 것입니다. 하지만 이는 작동하지 않습니다. 제가 이를 어떻게 알았는지 보여주는 것이 가치가 있는데, 왜냐하면 "플래그(flag)가 아무런 효과가 없었다"라는 사실은 벡터를 쳐다보는 것만으로는 증명하기 매우 어렵기 때문입니다.
긴 문서 하나를 가져와서 복사한 뒤, 마지막 문장만 바꿔보십시오. 만약 모델이 전체를 읽는다면, 두 복사본은 임베딩(embed) 결과가 약간 달라야 합니다. 만약 잘린다면(truncates), 두 복사본은 잘린 지점까지는 바이트 단위로 동일하며 벡터 또한 동일하게 반환됩니다.
filler = "The firewall configuration is stored in the usual place. " * 400
a = filler + " ZEBRA QUASAR MARMALADE."
b = filler + " The cat sat quietly on the warm mat."
이는 두 개의 매우 다른 결말을 가진 약 5,700 토큰(tokens)의 동일한 텍스트입니다. Ollama 0.32.4 버전에서는 다음과 같습니다:
num_ctx=None -> cosine(a,b)=1.000000
num_ctx=8192 -> cosine(a,b)=1.000000
플래그 설정 여부와 관계없이 소수점 여섯 자리까지 동일합니다. 두 경우 모두 한계를 넘어선 부분은 모두 버려졌습니다. 직접 찾아보신다면 Ollama 로그에서 클램핑(clamp) 현상을 확인할 수 있습니다. 경고(WARN) 메시지 한 줄이 뜨고 나면, 설정과 상관없이 모델은 2,048로 로드됩니다:
level=WARN source=server.go:114 msg="requested context size too large for model"
num_ctx=8192 n_ctx_train=2048
lama_context: n_ctx = 2048
num_ctx를 설정(setting)이 아닌 요청(request)으로 취급하십시오. 모델 카드(model card)나 제 말을 맹신하기보다는 제가 했던 방식대로 직접 확인해 보시기 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기