From 51f256b3406118b4fa5f696098ab56de0d983a7a Mon Sep 17 00:00:00 2001 From: bangae1 Date: Wed, 19 Nov 2025 20:03:04 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=A4=EB=89=B4=EC=96=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manual_offline.py | 170 ++++++++++++++++++++++------------------ manual_offline_think.py | 145 ++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 manual_offline_think.py diff --git a/manual_offline.py b/manual_offline.py index 1b88308..1b1356e 100644 --- a/manual_offline.py +++ b/manual_offline.py @@ -1,10 +1,9 @@ import os - import torch from sentence_transformers import SentenceTransformer import chromadb from chromadb.utils import embedding_functions -from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM +from transformers import AutoTokenizer, AutoModelForCausalLM from fastapi import FastAPI # # === 경로 설정 (모두 로컬) === @@ -22,105 +21,126 @@ collection = chroma_client.get_or_create_collection( _model = None _tokenizer = None -def get_qwen_model() : +def get_qwen_model(): global _model, _tokenizer - if _model is None: - model_name = "Qwen/Qwen3-0.6B" - _tokenizer = AutoTokenizer.from_pretrained( - QWEN_MODEL_PATH, - trust_remote_code=True, - local_files_only=True # 🔒 오프라인 강제 - ) - _model = AutoModelForCausalLM.from_pretrained( - QWEN_MODEL_PATH, - torch_dtype=torch.float32, # CPU 안정성 - device_map="auto", - trust_remote_code=True, - local_files_only=True # 🔒 오프라인 강제 - ) + if _model is not None: + return _model, _tokenizer + _tokenizer = AutoTokenizer.from_pretrained( + QWEN_MODEL_PATH, + trust_remote_code=True, + local_files_only=True + ) + _model = AutoModelForCausalLM.from_pretrained( + QWEN_MODEL_PATH, + dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + local_files_only=True + ) + # ✅ torch.compile() 적용 (PyTorch 2.0+) + if hasattr(torch, 'compile'): + try: + print("🚀 torch.compile() 적용 중...") + # mode="reduce-overhead": 추론 시 추천 + # dynamic=False: 고정 입력 크기 시 더 빠름 (보통 RAG는 입력 길이가 유동적이므로 dynamic=True 권장) + _model = torch.compile( + _model, + mode="reduce-overhead", # 또는 "max-autotune" (초기 컴파일 시간 ↑, 이후 속도 ↑↑) + dynamic=True # 입력 길이가 매번 다르므로 동적 크기 허용 + ) + print("✅ torch.compile() 성공!") + except Exception as e: + print(f"⚠️ torch.compile() 실패, 원본 모델 사용: {e}") + pass # 실패하면 그냥 원본 사용 return _model, _tokenizer -# 3. 질의 처리 -def query_and_summarize(job: str, query: str, top_k: int = 3): - - # 관련 문서 검색 +def query_and_summarize(job: str, query: str, top_k: int = 3, min_similarity: float = 0.2): + from datetime import datetime + print(f'1 {datetime.now()}') + # 관련 문서 검색 (top_k보다 여유 있게 가져옴) results = collection.query( query_texts=[query], - n_results=5, + n_results=top_k + 2, # 여유분 확보 where={"dept": job} ) - # results = collection.query(query_texts=[query], n_results=top_k) - cosine_similarities = [1 - d for d in results['distances'][0]] - print("유사도:", cosine_similarities) - # 출력 예: [0.610, 0.473, 0.154, 0.142] + print(f'{datetime.now()}') + if not results['documents'][0]: + return "관련 문서를 찾을 수 없습니다." - context_with_score = "" - for i, (doc, dist) in enumerate(zip(results['documents'][0], results['distances'][0])): - sim = 1 - dist - context_with_score += f"[문서 {i+1} | 유사도: {sim:.3f}]\n{doc}\n\n" + print(f'2 {datetime.now()}') + # 유사도 계산 및 필터링 + filtered_docs = [] + for doc, dist in zip(results['documents'][0], results['distances'][0]): + similarity = 1 - dist + if similarity >= min_similarity: + filtered_docs.append((doc, similarity)) + if len(filtered_docs) >= top_k: + break + print(f'3 {datetime.now()}') + if not filtered_docs: + return "유사도 기준을 만족하는 문서가 없습니다." - print(context_with_score) - print("\n\n\n\n\n") - top_doc = results['documents'][0][0] - - # ✅ 명시적으로 모델과 토크나이저 로드 + # 컨텍스트 생성 (유사도 내림차순 정렬은 이미 Chroma가 보장) + context_parts = [] + for i, (doc, sim) in enumerate(filtered_docs): + context_parts.append(f"[청크 {i+1} | 유사도: {sim:.3f}]\n{doc}") + context = "\n\n".join(context_parts) + print(f'4 {datetime.now()}') + # 모델 로드 model, tokenizer = get_qwen_model() - + print(f'5 {datetime.now()}') + # 프롬프트 구성 messages = [ - {"role": "system", "content": "당신은 회사 재무/회계 업무 전문 어시스턴트입니다. 문서 내용은 그대로 사용자에게 보여 줘야 하며 이를 기반으로 부가설명을 정확하고 상세하게 답변하세요."}, - {"role": "user", "content": f"다음 문서를 참고하세요:\n{top_doc}\n\n질문: {query}"} + { + "role": "system", + "content": ( + "당신은 회사 재무/회계 업무 전문 어시스턴트입니다. " + "사용자에게 제공된 여러 청크를 종합하여, 정확하고 상세하게 답변하세요. " + "필요시 문서 내용을 직접 인용하거나 요약해도 됩니다. " + "추측하지 말고, 문서에 근거한 정보만 사용하세요." + ) + }, + { + "role": "user", + "content": f"다음 문서들을 참고하세요:\n\n{context}\n\n질문: {query}" + } ] - + print(f'6 {datetime.now()}') + # 토큰화 및 생성 text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, - enable_thinking=True # Switches between thinking and non-thinking modes. Default is True. + enable_thinking=False # 작은 모델에선 비활성화 권장 ) model_inputs = tokenizer([text], return_tensors="pt").to(model.device) - - # conduct text completion + print(f'7 {datetime.now()}') generated_ids = model.generate( **model_inputs, - max_new_tokens=500 + max_new_tokens=150, + do_sample=True, # ✅ 샘플링 활성화 + temperature=0.3, + top_p=0.9, + pad_token_id=tokenizer.eos_token_id ) - output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist() - - # parsing thinking content - try: - # rindex finding 151668 () - index = len(output_ids) - output_ids[::-1].index(151668) - except ValueError: - index = 0 - - thinking_content = tokenizer.decode(output_ids[:index], skip_special_tokens=True).strip("\n") - end_think_id = tokenizer.convert_tokens_to_ids("") - if end_think_id in output_ids: - idx = len(output_ids) - output_ids[::-1].index(end_think_id) - else: - idx = 0 - content = tokenizer.decode(output_ids[idx:], skip_special_tokens=True).strip() - - print(top_doc) - print("\n\n\n\n\n") - print("thinking content:", thinking_content) - print("\n\n\n\n\n") - return content + print(f'8 {datetime.now()}') + input_len = model_inputs.input_ids.shape[1] + output_ids = generated_ids[0][input_len:] + response = tokenizer.decode(output_ids, skip_special_tokens=True).strip() + print(f'9 {datetime.now()}') + return response +# FastAPI 앱 app = FastAPI() @app.get("/") -def question(query: str) : - print(1) +def question(query: str): answer = query_and_summarize(job="FI", query=query) return {"answer": answer} -# 예시 사용 +# 개발용 실행 (직접 실행 시) if __name__ == "__main__": - # init(job="FI") - # FI : 재무 HR : 인사 - print(1) - # user_query = "외화 송금 방법?" - # answer = query_and_summarize(job="FI", query=user_query) - # print(answer) - # 실행방법 uvicorn manual:app --reload \ No newline at end of file + import uvicorn + print("서버 시작: uvicorn manual:app --reload") + # 예시 질의 (주석 해제 시 직접 테스트 가능) + # print(query_and_summarize("FI", "외화 송금 절차는 어떻게 되나요?")) \ No newline at end of file diff --git a/manual_offline_think.py b/manual_offline_think.py new file mode 100644 index 0000000..c4cdf12 --- /dev/null +++ b/manual_offline_think.py @@ -0,0 +1,145 @@ +import os +import torch +from sentence_transformers import SentenceTransformer +import chromadb +from chromadb.utils import embedding_functions +from transformers import AutoTokenizer, AutoModelForCausalLM +from fastapi import FastAPI + +# # === 경로 설정 (모두 로컬) === +QWEN_MODEL_PATH = "./models/Qwen3-0.6B" +EMBEDDING_MODEL_PATH = "./models/all-MiniLM-L6-v2" + +# 2. 벡터 DB 설정 +persist_directory = "./chroma_db" +chroma_client = chromadb.PersistentClient(path=persist_directory) + +collection = chroma_client.get_or_create_collection( + name="manuals", +) + +_model = None +_tokenizer = None + +def get_qwen_model() : + global _model, _tokenizer + if _model is not None: + return _model, _tokenizer + + # 토크나이저 로드 + _tokenizer = AutoTokenizer.from_pretrained( + QWEN_MODEL_PATH, + trust_remote_code=True, + local_files_only=True + ) + + # 모델 로드 + _model = AutoModelForCausalLM.from_pretrained( + QWEN_MODEL_PATH, + dtype=torch.bfloat16, # 또는 torch.bfloat16 (CPU), torch.float16 (GPU) + device_map="auto", + trust_remote_code=True, + local_files_only=True + ) + + # ✅ torch.compile() 적용 (PyTorch 2.0+) + if hasattr(torch, 'compile'): + try: + print("🚀 torch.compile() 적용 중...") + # mode="reduce-overhead": 추론 시 추천 + # dynamic=False: 고정 입력 크기 시 더 빠름 (보통 RAG는 입력 길이가 유동적이므로 dynamic=True 권장) + _model = torch.compile( + _model, + mode="reduce-overhead", # 또는 "max-autotune" (초기 컴파일 시간 ↑, 이후 속도 ↑↑) + dynamic=True # 입력 길이가 매번 다르므로 동적 크기 허용 + ) + print("✅ torch.compile() 성공!") + except Exception as e: + print(f"⚠️ torch.compile() 실패, 원본 모델 사용: {e}") + pass # 실패하면 그냥 원본 사용 + return _model, _tokenizer + +# 3. 질의 처리 +def query_and_summarize(job: str, query: str, top_k: int = 3): + + # 관련 문서 검색 + results = collection.query( + query_texts=[query], + n_results=5, + where={"dept": job} + ) + # results = collection.query(query_texts=[query], n_results=top_k) + cosine_similarities = [1 - d for d in results['distances'][0]] + print("유사도:", cosine_similarities) + # 출력 예: [0.610, 0.473, 0.154, 0.142] + + context_with_score = "" + for i, (doc, dist) in enumerate(zip(results['documents'][0], results['distances'][0])): + sim = 1 - dist + context_with_score += f"[문서 {i+1} | 유사도: {sim:.3f}]\n{doc}\n\n" + + print(context_with_score) + print("\n\n\n\n\n") + top_doc = results['documents'][0][0] + + # ✅ 명시적으로 모델과 토크나이저 로드 + model, tokenizer = get_qwen_model() + + messages = [ + {"role": "system", "content": "당신은 회사 재무/회계 업무 전문 어시스턴트입니다. 문서 내용은 그대로 사용자에게 보여 줘야 하며 이를 기반으로 부가설명을 정확하고 상세하게 답변하세요."}, + {"role": "user", "content": f"다음 문서를 참고하세요:\n{top_doc}\n\n질문: {query}"} + ] + + text = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=True # Switches between thinking and non-thinking modes. Default is True. + ) + model_inputs = tokenizer([text], return_tensors="pt").to(model.device) + + # conduct text completion + generated_ids = model.generate( + **model_inputs, + max_new_tokens=300, + do_sample=True, # ✅ 샘플링 활성화 + temperature=0.3, + top_p=0.9, + pad_token_id=tokenizer.eos_token_id + ) + output_ids = generated_ids[0][len(model_inputs.input_ids[0]):].tolist() + + # parsing thinking content + try: + # rindex finding 151668 () + index = len(output_ids) - output_ids[::-1].index(151668) + except ValueError: + index = 0 + + thinking_content = tokenizer.decode(output_ids[:index], skip_special_tokens=True).strip("\n") + end_think_id = tokenizer.convert_tokens_to_ids("") + if end_think_id in output_ids: + idx = len(output_ids) - output_ids[::-1].index(end_think_id) + else: + idx = 0 + content = tokenizer.decode(output_ids[idx:], skip_special_tokens=True).strip() + + print(top_doc) + print("\n\n\n\n\n") + print("thinking content:", thinking_content) + print("\n\n\n\n\n") + return content + +app = FastAPI() + +@app.get("/") +def question(query: str): + answer = query_and_summarize(job="FI", query=query) + return {"answer": answer} + +# 개발용 실행 (직접 실행 시) +if __name__ == "__main__": + import uvicorn + print("서버 시작: uvicorn manual:app --reload") + # 예시 질의 (주석 해제 시 직접 테스트 가능) + # print(query_and_summarize("FI", "외화 송금 절차는 어떻게 되나요?")) \ No newline at end of file