로컬 LLM은 인터넷으로 문서를 보내지 않고 답할 수 있지만, 모델 자체가 내 문서의 최신 내용을 아는 것은 아니다. RAG는 질문과 가까운 문서 조각을 먼저 찾고 그 근거를 프롬프트에 넣어 답하게 한다. 이 글은 Apple Silicon과 MLX를 중심으로 재현 가능한 최소 구조를 설명한다.
RAG의 네 단계
- 문서를 읽고 의미가 끊기지 않는 작은 조각으로 나눈다.
- 각 조각을 임베딩 벡터로 바꾸어 저장한다.
- 질문 벡터와 가까운 조각을 검색한다.
- 검색 결과와 질문을 MLX LLM에 전달해 근거 있는 답을 생성한다.
RAG의 품질은 큰 모델 하나보다 문서 정리, 조각 크기, 검색 평가, 출처 표시에 더 크게 좌우되기도 한다.
프로젝트 구조
local-rag/
documents/
index/
chunks.json
vectors.npy
ingest.py
ask.py
requirements.txt원문과 생성된 색인을 분리한다. 색인은 원문에서 다시 만들 수 있는 파생 데이터이므로 백업 정책도 다르게 적용할 수 있다.
문서 나누기
문자를 무조건 같은 길이로 자르면 제목과 설명이 분리된다. 먼저 Markdown 제목과 문단 경계를 존중하고, 너무 긴 문단만 토큰 기준으로 겹쳐 자르는 편이 좋다.
from pathlib import Path
def chunks(path: Path, limit: int = 900, overlap: int = 120):
text = path.read_text(encoding="utf-8")
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
result, current = [], ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip()
if len(candidate) <= limit:
current = candidate
continue
if current:
result.append(current)
current = (current[-overlap:] + "\n" + paragraph).strip()
if current:
result.append(current)
return result실서비스에서는 글자 수 대신 실제 임베딩 모델의 토크나이저로 길이를 계산한다. 각 조각에는 파일명, 제목, 수정 시각, 접근 권한 같은 메타데이터를 함께 둔다.
임베딩과 검색
임베딩 모델은 MLX LLM과 반드시 같을 필요가 없다. 한국어 성능, 라이선스, 벡터 차원, 최대 입력 길이를 확인해 별도 모델을 고른다. 벡터는 정규화한 뒤 코사인 유사도로 비교할 수 있다.
import numpy as np
def normalize(matrix: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(matrix, axis=1, keepdims=True)
return matrix / np.clip(norm, 1e-12, None)
def search(query_vector, document_vectors, top_k=5):
query = normalize(np.asarray(query_vector)[None, :])[0]
scores = normalize(document_vectors) @ query
indices = np.argsort(scores)[::-1][:top_k]
return [(int(i), float(scores[i])) for i in indices]문서 수가 수천 개 이하라면 NumPy 전수 검색도 충분히 단순하고 빠를 수 있다. 규모가 커지면 FAISS나 벡터 데이터베이스를 검토하되, 운영 복잡성이 늘어나는 비용도 계산한다.
MLX 모델에 근거 전달하기
`mlx-lm`의 정확한 함수 시그니처는 설치 버전에 따라 달라질 수 있으므로 버전을 고정하고 공식 예제를 확인한다. 핵심은 검색 결과를 명확한 경계와 출처로 묶는 것이다.
from mlx_lm import load, generate
model, tokenizer = load("mlx-community/example-4bit-model")
context = "\n\n".join(
f"[자료 {n}] 출처: {item['source']}\n{item['text']}"
for n, item in enumerate(results, 1)
)
prompt = f"""아래 자료만 근거로 답하세요.
자료에 답이 없으면 모른다고 말하세요.
답 마지막에 사용한 자료 번호를 적으세요.
{context}
질문: {question}
답변:"""
answer = generate(model, tokenizer, prompt=prompt, max_tokens=500)프롬프트만으로 환각이 완전히 사라지지는 않는다. 답에 인용된 조각이 실제 주장과 연결되는지 자동·수동 평가가 필요하다.
실패하기 쉬운 지점
- 조각이 너무 크면 관련 없는 문장이 함께 들어가고 문맥 창을 낭비한다.
- 너무 작으면 조건과 결론이 분리되어 의미가 사라진다.
- 비슷한 단어만 있는 문서가 상위에 오르면 재순위화가 필요하다.
- 삭제된 문서의 벡터가 남으면 오래된 답을 만든다.
- 사용자 권한을 검색 전에 적용하지 않으면 다른 사람의 문서가 노출된다.
- 프롬프트에 들어온 문서 안의 명령문을 신뢰하면 prompt injection에 취약하다.
평가 세트부터 만든다
실제 사용자가 물을 질문 20~50개와 정답 근거 문서를 표로 만든다. 각 변경 때 다음을 측정한다.
| 항목 | 확인 방법 | |
|---|---|
| 검색 재현율 | 정답 조각이 top-k 안에 포함되는가 | |
| 근거성 | 답의 각 주장을 검색 자료가 뒷받침하는가 | |
| 거절 품질 | 자료에 없는 질문을 모른다고 답하는가 | |
| 지연 시간 | 검색, 첫 토큰, 전체 생성 시간을 분리 측정 | |
| 메모리 | 모델 로드 후 최대 사용량과 동시 요청 증가량 | |
운영에 필요한 최소 안전장치
문서 색인 작업과 질문 API를 분리하고, 색인 버전을 기록한다. API에는 요청 크기 제한, 인증, 속도 제한, 타임아웃을 둔다. 로그에는 질문 전문이나 문서 원문 대신 요청 ID, 모델 버전, 색인 버전, 지연 시간처럼 진단에 필요한 정보만 남긴다.
MLX 프로세스는 인터넷에 직접 노출하지 않고 IDC의 Nginx나 애플리케이션 서버 뒤에 둔다. 터널이 끊겼을 때 무한 재시작하지 말고 지수 백오프와 운영자 알림을 사용한다.
다음 단계
두 번째 글에서는 하이브리드 검색과 reranker를 붙이고, 세 번째 글에서는 스트리밍 API·동시성·평가 자동화를 다룰 수 있다. 첫 단계에서는 구성요소를 늘리기보다 작은 정답 데이터로 검색 품질을 확인하는 것이 중요하다.
참고 자료
- MLX: https://github.com/ml-explore/mlx
- MLX Examples: https://github.com/ml-explore/mlx-examples
- OWASP LLM Prompt Injection: https://genai.owasp.org/llmrisk/llm01-prompt-injection/
댓글
0개아직 댓글이 없습니다.