프로젝트 개요
목록 | 내용 |
---|---|
프로젝트명 | 온프레미스 LLM+RAG 도메인 특화 대화형 AI 시스템 |
개발기간 | 2024년 9월 - 2025년 3월 (6개월) |
역할 | AI Agent 개발자 (프리랜서) |
기여도 | 90% (단독 개발) |
개발환경 | A100 80GB GPU 서버, 온프레미스 환경 |
비즈니스 배경 및 문제 정의
핵심과제
온프레미스 환경에서 도메인 특화 문서를 기반으로 한 정확한 질의응답 시스템 구축
기술적 제약사항:
- 외부 인터넷 연결 제한된 폐쇄망 환경
- 제한된 GPU 메모리 자원 (A100 80GB 서버, 실제 사용 가능 메모리 약 20GB)
- 대용량 모델 다운로드 및 배포의 어려움
- 네트워크 속도 제한으로 인한 모델 전송 문제
비즈니스 요구사항:
- 도메인 특화 문서 기반 정확한 답변 제공
- 질문 범위 제어를 통한 안전한 응답 시스템
- 실시간 응답 가능한 성능 최적화
- MVP 검증을 통한 사업 확장 가능성 입증
핵심 성과 지표
지표 | 달성값 | 측정 방법 |
---|---|---|
문서 검색 정확도 | 평균 0.7034 | Cosine similarity 기반 측정 |
응답 품질 | 0.6796 | LLM 생성 응답과 정답 간 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. 다단계 필터링 시스템 구축
도전과제
- 문제: 파인튜닝 후 일반 질문(“안녕”, “자전거가 뭐야”)에도 도메인 특화 정보가 혼입되는 현상
- 요구사항: 질문 범위 제어를 통한 안전하고 정확한 응답 시스템
- 복잡성: 파인튜닝 데이터 확장이나 프롬프트 수정만으로는 불확실성 존재
문제 해결 과정
대안 검토:
- 파인튜닝 데이터 확장 → 시간 제약으로 불가
- 프롬프트 수정만으로 해결 → 효과 불확실
선택
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 구조 부재로 인한 확장성 제한
- 검색 최적화: 단일 문서 기반 검색의 한계
시스템 고도화 방안
- 데이터 확장: 관련 문서 대량 수집 및 인덱싱
- Backend API 구축: RESTful API 기반 서비스 아키텍처 전환
- 실시간 업데이트: 문서 변경 시 자동 벡터 업데이트 시스템
- 멀티모달 확장: 텍스트 외 다양한 형태의 문서 처리
기술적 학습 및 성장
AI/ML 기술
- LLM 최적화: 대용량 모델의 메모리 효율적 배포 및 서빙
- RAG 시스템: 검색 증강 생성 시스템의 설계 및 최적화
- 파인튜닝: LoRA 기반 효율적 모델 적응 기법
- 벡터 검색: FAISS 기반 대용량 벡터 데이터베이스 구축
시스템 엔지니어링
- 모듈화 아키텍처: 각 기능별 독립적 분할, 개별 최적화 가능
- 온프레미스 배포: 제약된 환경에서의 AI 시스템 배포
- 성능 최적화: 메모리, 연산량, 응답 시간 최적화
- 모니터링: 실시간 시스템 성능 추적 및 디버깅
제약 조건 하에서의 창의적 해결
- 메모리 제약: 70B → 8B 모델 전환을 통한 근본적 해결
- 네트워크 제약: USB 물리적 전송을 통한 모델 배포
- 성능 제약: 다단계 필터링을 통한 효율성 최적화
비즈니스 요구사항과 기술적 해결책 연결
- 보안 요구사항: 온프레미스 환경에서의 완전한 독립적 운영
- 사용자 경험: 실시간 응답 및 직관적 디버깅 인터페이스
- 확장성: MVP 검증을 통한 단계적 시스템 발전
프로젝트 관리 및 협업
반복적 개발 사이클
- 단계별 MVP 검증: 각 기능별 점진적 검증 및 개선
- 성능 중심 개발: 정량적 지표 기반 지속적 최적화
- 프로젝트 설계 중심 개발: 프로젝트의 설계를 우선하고, 이를 중심으로 개발
기술 문서화
- 상세 구현 문서: 각 컴포넌트별 기술 사양 및 최적화 내용
- 성능 벤치마크: 단계별 성능 개선 과정 및 결과 추적
- 장애 대응: 문제 발생 시 원인 분석 및 해결 과정 문서화
결론 및 시사점
기술적 가치
- 온프레미스 LLM 배포: 제약된 환경에서의 대규모 AI 시스템 구축 경험
- RAG 시스템 최적화: 검색과 생성의 최적 결합을 통한 성능 향상
- 효율적 파인튜닝: 제한된 자원에서 최대 성능을 내는 모델 적응 기법
비즈니스 가치
- 보안 환경 AI 도입: 민감한 정보를 다루는 환경에서의 AI 활용 가능성 입증
- 단계적 시스템 발전: MVP 검증을 통한 리스크 최소화 및 점진적 확장
- 실무 적용성: 실제 업무 환경에서 즉시 활용 가능한 실용적 솔루션
기술적 발전
- 멀티모달 확장: 텍스트 외 다양한 형태의 정보 처리
- 실시간 학습: 사용자 피드백을 통한 지속적 모델 개선
- 분산 처리: 더 큰 규모의 문서 처리를 위한 분산 아키텍처
사업적 확장
- 도메인 확장: 다양한 전문 분야로의 시스템 적용
- API 서비스화: 외부 시스템과의 연동을 위한 API 플랫폼 구축
- 클라우드 하이브리드: 온프레미스와 클라우드의 최적 결합