온프레미스 LLM+RAG 시스템

kikiru328 | Jul 8, 2025 min read

Python LangChain Streamlit FAISS

한 장 요약본

프로젝트 개요

목록내용
프로젝트명온프레미스 LLM+RAG 도메인 특화 대화형 AI 시스템
개발기간2024년 9월 - 2025년 3월 (6개월)
역할AI Agent 개발자 (프리랜서)
기여도90% (단독 개발)
개발환경A100 80GB GPU 서버, 온프레미스 환경

비즈니스 배경 및 문제 정의

핵심과제

온프레미스 환경에서 도메인 특화 문서를 기반으로 한 정확한 질의응답 시스템 구축

기술적 제약사항:

  • 외부 인터넷 연결 제한된 폐쇄망 환경
  • 제한된 GPU 메모리 자원 (A100 80GB 서버, 실제 사용 가능 메모리 약 20GB)
  • 대용량 모델 다운로드 및 배포의 어려움
  • 네트워크 속도 제한으로 인한 모델 전송 문제

비즈니스 요구사항:

  • 도메인 특화 문서 기반 정확한 답변 제공
  • 질문 범위 제어를 통한 안전한 응답 시스템
  • 실시간 응답 가능한 성능 최적화
  • MVP 검증을 통한 사업 확장 가능성 입증

핵심 성과 지표

지표달성값측정 방법
문서 검색 정확도평균 0.7034Cosine similarity 기반 측정
응답 품질0.6796LLM 생성 응답과 정답 간 cosine 유사도
메모리 효율성약 80% 절약70B → 8B 모델 전환으로 달성 (파라미터 추정치)

기술 스택 및 아키텍처

핵심 기술 스택

# 주요 기술 구성
LLM       : Bllossom-8B # (LoRA 파인튜닝)
Serving   : vLLM        # (SamplingParams 최적화)
Vector DB : FAISS       
Embedding : jhgan-ko-sroberta-multitask-strans
Framework : LangChain
UI        : Streamlit
Deployment: 온프레미스 서버

시스템 아키텍처

  graph TD
    A[사용자 질문] --> B[다단계 필터링]
    B --> C{질문 분류}
    C -->|일반| D[기본 응답]
    C -->|도메인일반| F[전체 RAG 파이프라인]
    C -->|도메인특화| F[전체 RAG 파이프라인]
    F --> G[FAISS 벡터 검색]
    G --> H[문서 청킹]
    H --> I[Bllossom-8B LLM]
    D --> I
    I --> J[최종 응답]

주요 기술적 도전과 해결책

1. 대용량 모델 배포 & 메모리 최적화

도전과제

  • 목표: Bllossom-70B 모델을 A100 80GB 서버에 배포
  • 제약: 서버 메모리 할당 제한으로 실제 사용 가능 메모리 20GB 수준
  • 복잡성: 외부 네트워크 속도 제한으로 70B 모델 다운로드 어려움

문제 해결 과정

1단계: 물리적 모델 전송 해결

  • 외부 네트워크 속도 한계로 70B 모델 직접 다운로드 불가
  • 해결: USB를 통한 물리적 모델 파일 전송
  • 모델 파일을 외부에서 다운로드 후 USB로 A100 서버에 직접 설치

2단계: 메모리 최적화 시도

# 시도 1: 8-bit 양자화
bnb_config = BitsAndBytesConfig(
    load_in_8bit=True,
    llm_int8_threshold=6.0,
    llm_int8_skip_modules=None,
    llm_int8_enable_fp32_cpu_offload=False
)
# 시도 2: GPU 메모리 분산 처리
model = AutoModelForCausalLM.from_pretrained(
    model_path,
    device_map="auto",  # 자동 메모리 분산
    quantization_config=bnb_config
)
# accelerate 라이브러리 활용 모델 샤딩
결과 (Negative)

모든 최적화 시도에도 불구하고 CUDA OOM 오류 지속 발생

3단계: 전략적 모델 다운사이징*

  • 최종 결정: Bllossom-70B → Bllossom-8B 전환
  • 근거: 메모리 제약을 근본적으로 해결하면서 한국어 성능 유지
성과
  • 메모리 사용량 80% 절약
  • 안정적인 서비스 운영 가능

vLLM 최적화 설정

@st.cache_resource
def load_qna_model():
    tokenizer = AutoTokenizer.from_pretrained(VLLM_MODEL_PATH)
    tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = 'right'

    llm = LLM(
        model=VLLM_MODEL_PATH,
        dtype="float16",
        max_model_len=2048,         # VRAM 효율적 활용을 위한 최적 토큰 길이
        gpu_memory_utilization=0.4,  # 다른 프로세스와 안정적 공유 (0.6까지 가능하지만 안정성 고려)
        enforce_eager=True,         # Streamlit 동기 처리로 사용자 만족도 개선
    )
    return tokenizer, llm

# 추론 설정 최적화
sampling = SamplingParams(
    stop_token_ids=[tokenizer.eos_token_id],
    temperature=0.7,
    top_p=0.9,
    max_tokens=1024,    # 답변 길이 최적화로 낭비 방지
)

2. 다단계 필터링 시스템 구축

도전과제

  • 문제: 파인튜닝 후 일반 질문(“안녕”, “자전거가 뭐야”)에도 도메인 특화 정보가 혼입되는 현상
  • 요구사항: 질문 범위 제어를 통한 안전하고 정확한 응답 시스템
  • 복잡성: 파인튜닝 데이터 확장이나 프롬프트 수정만으로는 불확실성 존재

문제 해결 과정

대안 검토:

  1. 파인튜닝 데이터 확장 → 시간 제약으로 불가
  2. 프롬프트 수정만으로 해결 → 효과 불확실
선택

3단계 필터링 시스템 구축 (리소스 효율성 + 확실성 확보)

3단계 필터링 아키텍처 설계
  • 1단계: 금지어 키워드 사전 매칭
RULE_FILTER: list = [
    {
      "rule_type": "blocked",
      "keywords": ["blocked keywords"],
      "reason": "금지된 내용입니다.",
      "response": "해당 질문은 처리할 수 없습니다."
    }
  ]

def load_filters() -> dict:
    with open(RULE_FILTER_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

def filtering_by_rules(user_input: str) -> tuple[bool, str | None, str | None]:
    rules = load_filters()
    lowered_input = user_input.lower()
    
    for rule in rules:
        rule_type = rule.get("rule_type")
        
        for keyword in rule.get("keywords", []):
            if keyword in lowered_input:
                reason = rule.get("reason", "")
                response = rule.get("response", None)
                return FilterResult(
                    passed=False,
                    reason=reason,
                    response=response,
                    rule_type=rule_type
                )
    return FilterResult(passed=True)        
  • 2단계: 질문 분류 시스템
# 2-1. SentenceTransformer 에 Embed 모델 적용
def load_similarity_classifier() -> tuple:
    model = SentenceTransformer(EMBED_MODEL_PATH)   

    with open(SEMANTIC_CLF_EX_PATH, "r", encoding="UTF-8") as f:
        examples = json.load(f)

    example_embeddings = {
        label: model.encode(sentences) for label, sentences in examples.items()
    }

    return model, example_embeddings

#2-2. 질문 -> 임베딩 -> 예시와 비교, 유사도 분류
def classify_by_similarity(
    user_input: str, 
    model: SentenceTransformer, 
    example_embeddings: Dict[str, np.ndarray]) -> str:
    input_embedding = model.encode([user_input])
    similarities = {
        label: np.mean(cosine_similarity(input_embedding, embs))\
             for label, embs in example_embeddings.items()
    }
    
    # Debug
    logger.debug(f"user input: {user_input}")
    for label, score in similarities.items():
        logger.debug(f"DEBUG {label} 유사도: {score:.4f}")

    best_label = max(similarities, key=similarities.get)
    return best_label

# 2-3. 분류 모델
def classifier_system_prompt(user_input: str) -> str:
    #키워드 명시
    domain_general_keywords = ["기본개념", "일반상식", "용어설명"],
    domain_specific_keywords = ["전문용어", "특화정보", "세부규정"]
    
    # 키워드 매칭 시도
    if contains_domain_general and contains_domain_specific:
        return "domain_specific"
    elif contains_domain_specific:
        return "domain_specific"
    elif contains_domain_general:
        return "domain_general"
    
    # 2-2. SentenceTransformer 기반 2차 분류
    # 각 카테고리별 30개씩 구성된 질문 사전(총 90개)과 유사도 비교
    model, example_embeddings = load_similarity_classifier()
    return classify_by_similarity(user_input, model, example_embeddings)
  • 3단계: 카테고리별 차별화된 처리
# 3-1. 일반 질문에 대한 답변 명시
def general(request: PromptRequest):
    generated_prompt = generate_prompt(
        classification_result=request.classification_result,
        user_input=request.user_input,
        context=request.context,
        tokenizer=request.tokenizer
    )
    sampling = SamplingParams(
        stop_token_ids=[request.tokenizer.eos_token_id],
        temperature=0.3,
        top_p=0.7,
        max_tokens=64,
    )
    outputs = request.llm.generate(generated_prompt, sampling)
    answer = "".join([o.outputs[0].text for o in outputs])
    
    # Trained 된 모델에 대해서 응답 처리
    section_pattern = r"관련 X:.*"
    answer = re.sub(section_pattern, "", answer).strip()
    
    if len(answer) < 5:
        answer = "질문과 관련된 정보를 찾을 수 없습니다. 다른 질문을 해주세요."
        
    return generated_prompt, answer

# 3-2. 도메인 일반 / 특화 된 부분에 따라서 차별 응답
def generate_llm_response(request: PromptRequest, docs: list = None):
    if request.classification_result == "general":
        return general(request)
    
    prompt, answer = detail(request)
        
    if request.classification_result in ["domain_specific", "domain_general"]:
        answer = format_response(answer)
        
    return prompt, answer
성과
  • 시스템 효율성: 질문 유형별 차별화된 처리로 불필요한 RAG 연산 대폭 감소
  • 응답 품질: 카테고리별 최적화된 프롬프트 적용으로 정확도 향상
  • 안전성: 도메인 외 질문에 대한 안전한 응답 체계 구축
  • 리소스 최적화: 전체 질문 중 RAG 파이프라인 적용 비율 최소화

3. RAG 파이프라인 최적화

도전과제

  • 문제: 도메인 특화 문서(1개)의 구조와 참조 관계로 인한 청킹 어려움
  • 복잡성: 문서 구조 파괴 시 문맥 손실, 너무 큰 청크 시 검색 정확도 저하
  • 성능: 검색 정확도와 응답 품질 간의 트레이드오프 최적화

문제 해결 과정

  • 1단계: 문서 처리 방식 실험
# 다양한 청킹 전략 테스트
chunk_size_tests = [100, 300, 500, 1000]
processing_methods = ["PDF_OCR", "PDF_direct", "Markdown_conversion"]

# 최적 조합 탐색
for chunk_size in chunk_size_tests:
    for method in processing_methods:
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=200,  # 고정값
            separators=["\n\n", "\n", " "]
        )
        # 육안 검토를 통한 완성도 평가
        evaluate_chunk_quality(splitter, method)

2단계: 최적 설정 도출

  • 최종 선택: chunk_size=500, overlap=200, pymupdf4llm 기반 PDF → Markdown 변환
  • 근거: 육안 검토 결과 완성도가 가장 높은 설정
  • 검증: 구조 보존과 검색 정확도 간의 최적 균형점 달성
def get_text_splitter(chunk_size=500, chunk_overlap=200):
    return RecursiveCharacterTextSplitter(
        separators=["\n\n", "\n", " "],
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        is_separator_regex=False
    )

3단계: 벡터화 및 검색 최적화

# FAISS 벡터 데이터베이스 구성
# 한국어 특화 임베딩 모델 사용
embed_model = HuggingFaceEmbeddings(
    model_name='../models/jhgan/ko-sroberta-multitask-strans',
    model_kwargs={"device": "cuda:0"},
    encode_kwargs={"normalize_embeddings": True},
)

vector_store = FAISS.from_documents(
    split_documents,
    embedding=embed_model,
    distance_strategy=DistanceStrategy.COSINE
)
# 검색 최적화 -> faiss - merge - context로 반환
def run_retriever(user_input: str, classification_result: str):
    if classification_result == "general":
        # do not run retriever
        return [], ""
    
    retriever = get_faiss_retriever()
    docs = retriever.invoke(user_input)
    context = merge_docs(docs=docs)
    return docs, context

4. 배포 및 서비스 아키텍처

도전과제

  • 환경 제약: 폐쇄망 환경에서 외부 의존성 최소화
  • 성능: 실시간 응답 가능한 서비스 구조
  • 안정성: 24시간 운영 가능한 시스템 구축

서비스 아키텍처 설계

# Streamlit 기반 통합 서비스
import streamlit as st
import asyncio
from concurrent.futures import ThreadPoolExecutor

# 비동기 처리 환경 설정
try:
    asyncio.get_running_loop()
except RuntimeError:
    asyncio.set_event_loop(asyncio.new_event_loop())

# 서비스 구성요소
def main()
    try:
        # wordcloud service
        if is_wordcloud_request(user_input=user_input):
            with st.spinner("워드클라우드 생성 중"):
                handle_wordcloud_request(role="assistant")
            st.success("워드클라우드 생성 완료")
            return
        
        # filtering by rules (passed, reason, response, rule_type)
        filtered_result = filtering_by_rules(user_input=user_input)

        if not filtered_result.passed: # filtered: No RAG, No LLM
            if filtered_result.response: # 답변이 있을 시
                st.chat_message("assistant").write(response)
                add_message("assistant", response)
            st.warning(f"[오류] {reason}") # 차단 내용 설명
            
            return # 답변 끝
        
        # classifier
        classification_result = classifier_system_prompt(user_input=user_input)
        
        # RAG
        docs, context = run_retriever(user_input=user_input,
                                      classification_result=classification_result)
        
        # LLM
        tokenizer, llm = load_qna_model() # load model and save in cache
        
        request = PromptRequest(
            classification_result=classification_result,
            user_input=user_input,
            context=context,
            tokenizer=tokenizer,
            llm=llm
        )
        
        prompt, answer = generate_llm_response(request=request, docs=docs)
        st.chat_message("assistant").markdown(answer) # answer format in markdown
        add_message("assistant", answer)

디버깅 및 모니터링 시스템

# 실시간 디버깅 UI 구현
def debug_interface():
    #Debug
    st.markdown(f"# 질문: {user_input}")
    
    add_message("user", user_input)
    
    try:
        # wordcloud service
        if is_wordcloud_request(user_input=user_input):
            with st.spinner("워드클라우드 생성 중"):
                handle_wordcloud_request(role="assistant")
            st.success("워드클라우드 생성 완료")
            return
        
        # filtering by rules (passed, reason, response, rule_type)
        filtered_result = filtering_by_rules(user_input=user_input)

        if not filtered_result.passed: # filtered: No RAG, No LLM
            if filtered_result.response: # 답변이 있을 시
                st.chat_message("assistant").write(response)
                add_message("assistant", response)
            st.warning(f"[오류] {reason}") # 차단 내용 설명
            
            return # 답변 끝
        
        # classifier
        classification_result = classifier_system_prompt(user_input=user_input)
        
        st.markdown(f"## classification_result: {classification_result}")
        # RAG
        docs, context = run_retriever(user_input=user_input,
                                      classification_result=classification_result)
        
        st.markdown(f"#### 검색된 문서 수 : {len(docs)}")
        st.markdown("### 검색 문서 전체 내용")
        
        if docs: # not empty
            for i, doc in enumerate(docs, 1):
                st.markdown(f"**문서 {i}**")
                st.code(doc.page_content)
        else:
            st.markdown("RAG가 적용되지 않았습니다.")
            st.markdown(f"prompt type: {classification_result}")
        
        # LLM
        tokenizer, llm = load_qna_model() # load model and save in cache
        
        request = PromptRequest(
            classification_result=classification_result,
            user_input=user_input,
            context=context,
            tokenizer=tokenizer,
            llm=llm
        )
        
        prompt, answer = generate_llm_response(request=request, docs=docs)
        
        st.markdown("### 생성된 프롬프트")
        st.code(prompt[:500])

        st.markdown("### LLM 응답")
        st.chat_message("assistant").markdown(answer) # answer format in markdown
        add_message("assistant", answer)

5. LoRA 파인튜닝 최적화

도전과제

  • 목표: 도메인 특화 성능 향상을 위한 효율적 파인튜닝
  • 제약: 제한된 GPU 메모리에서 8B 모델 파인튜닝

파인튜닝 구현

# LoRA 설정 최적화
config = LoraConfig(
    r=8,                    # 랭크 - 메모리와 성능 간 균형
    alpha=32,               # 스케일링 팩터
    dropout=0.1,            # 오버피팅 방지
    target_modules=[
        "q_proj", "o_proj", "k_proj", "v_proj",  # 어텐션 레이어
        "gate_proj", "up_proj", "down_proj"       # MLP 레이어
    ],
    bias="none",
    task_type="CAUSAL_LM"
)

# 훈련 설정
trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    args=TrainingArguments(
        output_dir="outputs",           # 학습 결과 저장 경로
        #num_train_epochs=1,            # 학습 에폭 수
        max_steps=300,                  # 최대 학습 스텝 수 // check: 300 250318
        per_device_train_batch_size=16, # 배치 크기 설정
        gradient_accumulation_steps=4,  # 그래디언트 누적 단계 수
        optim="paged_adamw_8bit",       # 8bit 최적화 알고리즘
        warmup_steps=0,                 # 워밍업 스텝 비활성화
        learning_rate=2e-4,             # 학습률 설정
        fp16=True,                      # 16비트 연산 활성화 (메모리 사용량 감소)
        logging_steps=100,              # 로그 출력 간격
        push_to_hub=False,              # Hugging Face Hub 푸시 비활성화
        report_to='none',               # 로그 리포팅 비활성화
    ),
    peft_config=lora_config,            # LoRA 설정 전달
    formatting_func=prompts,            # 프롬프트 포맷 함수 적용
)

성능 평가 및 검증

데이터셋 구성

  • 평가 데이터: 30개 도메인 특화 instruction-output 쌍
  • 검증 방식: 전문가 검증 완료된 질문-답변 세트
  • 평가 지표:
    • 검색 정확도: Cosine similarity 기반 문서 검색 성능
    • 응답 품질: 생성 답변과 정답 간 의미 유사도

핵심 발견

  • 병목 지점: Retriever 단계가 전체 성능의 핵심 결정 요소
  • 개선 방향: 더 많은 문서 수집 및 인덱싱 최적화 필요성 확인
  • 시사점: Generator(LLM) 성능보다 검색 품질이 더 중요한 요소

비즈니스 임팩트 및 성과

기술적 성과

  • MVP 검증 완료: 실제 클라이언트 환경에서 시스템 구동 확인
  • 성능 벤치마크: 검색 정확도 0.7034, 응답 품질 0.6796 달성
  • 시스템 안정성: 온프레미스 환경에서 24시간 안정적 운영

비즈니스 성과

  • 사업 확장: 추가 예산 배정으로 시스템 고도화 사업 확장 결정
  • 기술 리더십: 후속 시스템 고도화 사업 우선 개발자로 지명
  • 솔루션 가치: 보안 환경에서의 AI 시스템 구축 가능성 입증

후속 개발 계획

현재 한계점 분석

  • 데이터 제약: Vectorstore 문서량 부족으로 인한 성능 제약
  • 아키텍처: Backend API 구조 부재로 인한 확장성 제한
  • 검색 최적화: 단일 문서 기반 검색의 한계

시스템 고도화 방안

  1. 데이터 확장: 관련 문서 대량 수집 및 인덱싱
  2. Backend API 구축: RESTful API 기반 서비스 아키텍처 전환
  3. 실시간 업데이트: 문서 변경 시 자동 벡터 업데이트 시스템
  4. 멀티모달 확장: 텍스트 외 다양한 형태의 문서 처리

기술적 학습 및 성장

AI/ML 기술

  • LLM 최적화: 대용량 모델의 메모리 효율적 배포 및 서빙
  • RAG 시스템: 검색 증강 생성 시스템의 설계 및 최적화
  • 파인튜닝: LoRA 기반 효율적 모델 적응 기법
  • 벡터 검색: FAISS 기반 대용량 벡터 데이터베이스 구축

시스템 엔지니어링

  • 모듈화 아키텍처: 각 기능별 독립적 분할, 개별 최적화 가능
  • 온프레미스 배포: 제약된 환경에서의 AI 시스템 배포
  • 성능 최적화: 메모리, 연산량, 응답 시간 최적화
  • 모니터링: 실시간 시스템 성능 추적 및 디버깅

제약 조건 하에서의 창의적 해결

  • 메모리 제약: 70B → 8B 모델 전환을 통한 근본적 해결
  • 네트워크 제약: USB 물리적 전송을 통한 모델 배포
  • 성능 제약: 다단계 필터링을 통한 효율성 최적화

비즈니스 요구사항과 기술적 해결책 연결

  • 보안 요구사항: 온프레미스 환경에서의 완전한 독립적 운영
  • 사용자 경험: 실시간 응답 및 직관적 디버깅 인터페이스
  • 확장성: MVP 검증을 통한 단계적 시스템 발전

프로젝트 관리 및 협업

반복적 개발 사이클

  1. 단계별 MVP 검증: 각 기능별 점진적 검증 및 개선
  2. 성능 중심 개발: 정량적 지표 기반 지속적 최적화
  3. 프로젝트 설계 중심 개발: 프로젝트의 설계를 우선하고, 이를 중심으로 개발

기술 문서화

  1. 상세 구현 문서: 각 컴포넌트별 기술 사양 및 최적화 내용
  2. 성능 벤치마크: 단계별 성능 개선 과정 및 결과 추적
  3. 장애 대응: 문제 발생 시 원인 분석 및 해결 과정 문서화

결론 및 시사점

기술적 가치

  • 온프레미스 LLM 배포: 제약된 환경에서의 대규모 AI 시스템 구축 경험
  • RAG 시스템 최적화: 검색과 생성의 최적 결합을 통한 성능 향상
  • 효율적 파인튜닝: 제한된 자원에서 최대 성능을 내는 모델 적응 기법

비즈니스 가치

  • 보안 환경 AI 도입: 민감한 정보를 다루는 환경에서의 AI 활용 가능성 입증
  • 단계적 시스템 발전: MVP 검증을 통한 리스크 최소화 및 점진적 확장
  • 실무 적용성: 실제 업무 환경에서 즉시 활용 가능한 실용적 솔루션

기술적 발전

  • 멀티모달 확장: 텍스트 외 다양한 형태의 정보 처리
  • 실시간 학습: 사용자 피드백을 통한 지속적 모델 개선
  • 분산 처리: 더 큰 규모의 문서 처리를 위한 분산 아키텍처

사업적 확장

  • 도메인 확장: 다양한 전문 분야로의 시스템 적용
  • API 서비스화: 외부 시스템과의 연동을 위한 API 플랫폼 구축
  • 클라우드 하이브리드: 온프레미스와 클라우드의 최적 결합