diff --git a/backend/services/context_retrieval.py b/backend/services/context_retrieval.py new file mode 100644 index 0000000..e5e25fe --- /dev/null +++ b/backend/services/context_retrieval.py @@ -0,0 +1,266 @@ +""" +컨텍스트 기반 검색 시스템 +- 컨텍스트 임베딩: 질문과 문서를 함께 임베딩하여 더 정확한 검색 +- 컨텍스트 BM25: 질문과 문서의 컨텍스트를 고려한 키워드 검색 +- Reranker: 검색 결과를 재순위화하여 정확도 향상 +""" + +import logging +import numpy as np +from typing import List, Dict, Any, Tuple +from rank_bm25 import BM25Okapi +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity +import re +from collections import Counter + +logger = logging.getLogger(__name__) + +class ContextEmbedding: + """컨텍스트 임베딩을 통한 검색""" + + def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"): + self.model = SentenceTransformer(model_name) + logger.info(f"✅ 컨텍스트 임베딩 모델 로드 완료: {model_name}") + + def create_context_embedding(self, question: str, document: str) -> np.ndarray: + """질문과 문서를 함께 임베딩하여 컨텍스트 임베딩 생성""" + # 질문과 문서를 결합하여 컨텍스트 생성 + context = f"질문: {question}\n문서: {document}" + embedding = self.model.encode(context) + return embedding + + def search_with_context(self, question: str, documents: List[Dict[str, Any]], top_k: int = 10) -> List[Dict[str, Any]]: + """컨텍스트 임베딩을 사용한 검색""" + logger.info(f"🔍 컨텍스트 임베딩 검색 시작: {len(documents)}개 문서") + + # 질문 임베딩 생성 + question_embedding = self.model.encode(question) + + # 각 문서에 대해 컨텍스트 임베딩 생성 및 유사도 계산 + scored_documents = [] + for doc in documents: + doc_content = doc.get('content', '') + if not doc_content: + continue + + # 컨텍스트 임베딩 생성 + context_embedding = self.create_context_embedding(question, doc_content) + + # 코사인 유사도 계산 + similarity = cosine_similarity([question_embedding], [context_embedding])[0][0] + + scored_documents.append({ + 'document': doc, + 'context_score': similarity, + 'content': doc_content + }) + + # 유사도 기준으로 정렬 + scored_documents.sort(key=lambda x: x['context_score'], reverse=True) + + logger.info(f"📊 컨텍스트 임베딩 검색 완료: {len(scored_documents)}개 결과") + return scored_documents[:top_k] + +class ContextBM25: + """컨텍스트 BM25를 통한 검색""" + + def __init__(self): + self.bm25 = None + self.documents = [] + logger.info("✅ 컨텍스트 BM25 초기화 완료") + + def preprocess_text(self, text: str) -> List[str]: + """텍스트 전처리 및 토큰화""" + # 한글, 영문, 숫자만 추출 + text = re.sub(r'[^\w\s가-힣]', ' ', text) + # 공백으로 분리 + tokens = text.split() + # 빈 토큰 제거 + tokens = [token.strip() for token in tokens if token.strip()] + return tokens + + def build_index(self, documents: List[Dict[str, Any]]): + """BM25 인덱스 구축""" + self.documents = documents + corpus = [] + + for doc in documents: + content = doc.get('content', '') + tokens = self.preprocess_text(content) + corpus.append(tokens) + + self.bm25 = BM25Okapi(corpus) + logger.info(f"📚 BM25 인덱스 구축 완료: {len(corpus)}개 문서") + + def search_with_context(self, question: str, top_k: int = 10) -> List[Dict[str, Any]]: + """컨텍스트 BM25를 사용한 검색""" + if not self.bm25: + logger.warning("⚠️ BM25 인덱스가 구축되지 않았습니다.") + return [] + + logger.info(f"🔍 컨텍스트 BM25 검색 시작: {question}") + + # 질문 토큰화 + question_tokens = self.preprocess_text(question) + + # BM25 점수 계산 + scores = self.bm25.get_scores(question_tokens) + + # 점수와 문서를 매핑 + scored_documents = [] + for i, (doc, score) in enumerate(zip(self.documents, scores)): + scored_documents.append({ + 'document': doc, + 'bm25_score': score, + 'content': doc.get('content', '') + }) + + # 점수 기준으로 정렬 + scored_documents.sort(key=lambda x: x['bm25_score'], reverse=True) + + logger.info(f"📊 컨텍스트 BM25 검색 완료: {len(scored_documents)}개 결과") + return scored_documents[:top_k] + +class Reranker: + """검색 결과 재순위화""" + + def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"): + self.model = SentenceTransformer(model_name) + logger.info(f"✅ Reranker 모델 로드 완료: {model_name}") + + def calculate_relevance_score(self, question: str, document_content: str) -> float: + """질문과 문서의 관련성 점수 계산""" + # 질문과 문서의 임베딩 생성 + question_embedding = self.model.encode(question) + doc_embedding = self.model.encode(document_content) + + # 코사인 유사도 계산 + similarity = cosine_similarity([question_embedding], [doc_embedding])[0][0] + + # 키워드 매칭 점수 추가 + keyword_score = self._calculate_keyword_score(question, document_content) + + # 최종 점수 (임베딩 유사도 70% + 키워드 매칭 30%) + final_score = 0.7 * similarity + 0.3 * keyword_score + + return final_score + + def _calculate_keyword_score(self, question: str, document: str) -> float: + """키워드 매칭 점수 계산""" + # 질문에서 키워드 추출 + question_tokens = re.findall(r'\b\w+\b', question.lower()) + doc_tokens = re.findall(r'\b\w+\b', document.lower()) + + # 토큰 빈도 계산 + question_counter = Counter(question_tokens) + doc_counter = Counter(doc_tokens) + + # 공통 토큰의 가중치 계산 + common_tokens = set(question_tokens) & set(doc_tokens) + if not common_tokens: + return 0.0 + + # TF-IDF 스타일 점수 계산 + total_score = 0.0 + for token in common_tokens: + question_freq = question_counter[token] + doc_freq = doc_counter[token] + # 간단한 TF-IDF 스타일 점수 + score = (question_freq * doc_freq) / (len(question_tokens) * len(doc_tokens)) + total_score += score + + return min(total_score, 1.0) # 최대 1.0으로 제한 + + def rerank_documents(self, question: str, documents: List[Dict[str, Any]], top_k: int = 10) -> List[Dict[str, Any]]: + """문서 재순위화""" + logger.info(f"🔄 Reranker 재순위화 시작: {len(documents)}개 문서") + + reranked_documents = [] + for doc_info in documents: + content = doc_info.get('content', '') + if not content: + continue + + # 관련성 점수 계산 + relevance_score = self.calculate_relevance_score(question, content) + + # 기존 점수와 재순위화 점수 결합 + original_score = doc_info.get('context_score', 0) + doc_info.get('bm25_score', 0) + final_score = 0.6 * relevance_score + 0.4 * original_score + + reranked_documents.append({ + **doc_info, + 'rerank_score': relevance_score, + 'final_score': final_score + }) + + # 최종 점수 기준으로 정렬 + reranked_documents.sort(key=lambda x: x['final_score'], reverse=True) + + logger.info(f"📊 Reranker 재순위화 완료: {len(reranked_documents)}개 결과") + return reranked_documents[:top_k] + +class ContextRetrieval: + """컨텍스트 기반 검색 시스템 통합 클래스""" + + def __init__(self, model_name: str = "jhgan/ko-sroberta-multitask"): + self.context_embedding = ContextEmbedding(model_name) + self.context_bm25 = ContextBM25() + self.reranker = Reranker(model_name) + logger.info("✅ 컨텍스트 검색 시스템 초기화 완료") + + def build_index(self, documents: List[Dict[str, Any]]): + """검색 인덱스 구축""" + logger.info(f"📚 검색 인덱스 구축 시작: {len(documents)}개 문서") + self.context_bm25.build_index(documents) + logger.info("✅ 검색 인덱스 구축 완료") + + def search(self, question: str, top_k: int = 10) -> List[Dict[str, Any]]: + """컨텍스트 기반 통합 검색""" + logger.info(f"🔍 컨텍스트 기반 통합 검색 시작: {question}") + + # 1. 컨텍스트 임베딩 검색 + embedding_results = self.context_embedding.search_with_context(question, self.context_bm25.documents, top_k * 2) + + # 2. 컨텍스트 BM25 검색 + bm25_results = self.context_bm25.search_with_context(question, top_k * 2) + + # 3. 두 결과를 결합하여 중복 제거 + combined_results = self._combine_results(embedding_results, bm25_results) + + # 4. Reranker로 재순위화 + final_results = self.reranker.rerank_documents(question, combined_results, top_k) + + logger.info(f"📊 컨텍스트 기반 통합 검색 완료: {len(final_results)}개 결과") + return final_results + + def _combine_results(self, embedding_results: List[Dict], bm25_results: List[Dict]) -> List[Dict]: + """임베딩과 BM25 결과를 결합하여 중복 제거""" + combined = {} + + # 임베딩 결과 추가 + for result in embedding_results: + doc_id = id(result['document']) + if doc_id not in combined: + combined[doc_id] = result + else: + # 기존 점수와 새 점수 결합 + combined[doc_id]['context_score'] = max( + combined[doc_id].get('context_score', 0), + result['context_score'] + ) + + # BM25 결과 추가 + for result in bm25_results: + doc_id = id(result['document']) + if doc_id not in combined: + combined[doc_id] = result + else: + # 기존 점수와 새 점수 결합 + combined[doc_id]['bm25_score'] = max( + combined[doc_id].get('bm25_score', 0), + result['bm25_score'] + ) + + return list(combined.values()) diff --git a/backend/services/langchain_service.py b/backend/services/langchain_service.py index 8536a35..9e72d3b 100644 --- a/backend/services/langchain_service.py +++ b/backend/services/langchain_service.py @@ -6,7 +6,6 @@ LangChain v0.3 기반 AI 서비스 import os import logging from typing import List, Dict, Any, Optional -from datetime import datetime from .context_retrieval import ContextRetrieval # LangChain Core @@ -16,8 +15,6 @@ from langchain_core.vectorstores import VectorStore from langchain_core.retrievers import BaseRetriever from langchain_core.language_models import BaseLanguageModel from langchain_core.prompts import PromptTemplate -from langchain_core.output_parsers import StrOutputParser -from langchain_core.runnables import RunnablePassthrough, RunnableParallel # LangChain Community from langchain_community.vectorstores import Chroma @@ -25,7 +22,6 @@ from langchain_community.embeddings import SentenceTransformerEmbeddings from langchain_community.llms import Ollama # LangChain Chains -from langchain.chains import RetrievalQA from langchain.chains.combine_documents import create_stuff_documents_chain from langchain.chains import create_retrieval_chain @@ -696,29 +692,6 @@ class LangChainRAGService: return max(0.0, score) # 음수 점수 방지 - def _verify_content_relevance(self, content: str, question: str) -> bool: - """문서 내용이 질문과 실제로 관련이 있는지 검증 (간소화)""" - if not content or not question: - return False - - content_lower = content.lower() - question_keywords = self._extract_keywords(question) - - # 명백히 관련 없는 페이지 패턴들 - irrelevant_patterns = [ - '개정번호', '개정항목', '개정사유', '발효일자', '신규제정', - '바인더 표지', '보관번호', '보관목록', '관리부서', - 'prepared by', 'format no', 'storage number' - ] - - if any(pattern in content_lower for pattern in irrelevant_patterns): - return False - - # 키워드 매칭 확인 (완화된 버전) - keyword_matches = sum(1 for keyword in question_keywords if keyword in content_lower) - - # 최소 1개 이상의 키워드가 매칭되면 관련성이 있다고 판단 (2→1로 완화) - return keyword_matches >= 1 def _verify_answer_document_relevance(self, answer: str, doc_content: str, question: str) -> bool: """답변과 문서 내용의 직접적 연관성 검증""" diff --git a/frontend/public/images/chatbot_icon.svg b/frontend/public/images/chatbot_icon.svg new file mode 100644 index 0000000..d872f99 --- /dev/null +++ b/frontend/public/images/chatbot_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + W + diff --git a/frontend/src/components/SimpleMarkdownRenderer.tsx b/frontend/src/components/SimpleMarkdownRenderer.tsx new file mode 100644 index 0000000..0f6186e --- /dev/null +++ b/frontend/src/components/SimpleMarkdownRenderer.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface DetailedReference { + filename: string; + file_id: string; + page_number: number; + chunk_index?: number; + content_preview?: string; + full_content?: string; + is_relevant?: boolean; +} + +interface SimpleMarkdownRendererProps { + content: string; + detailedReferences?: DetailedReference[]; + onReferenceClick?: (fileId: string, pageNumber: number, filename: string) => void; +} + +const SimpleMarkdownRenderer: React.FC = ({ + content, + detailedReferences = [], + onReferenceClick +}) => { + // 마크다운 렌더링 함수 + const renderMarkdown = (text: string): React.ReactNode => { + return ( + ( + + {children} + + ), + // 헤딩 스타일 + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + // 문단 스타일 + p: ({ children }) => ( +

+ {children} +

+ ), + // 리스트 스타일 + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + // 강조 스타일 + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + + {children} + + ), + // 코드 스타일 + code: ({ children }) => ( + + {children} + + ), + pre: ({ children }) => ( +
    +              {children}
    +            
    + ), + // 인용 스타일 + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // 테이블 스타일 + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + }} + > + {text} +
    + ); + }; + + return ( +
    + {renderMarkdown(content)} +
    + ); +}; + +export default SimpleMarkdownRenderer; \ No newline at end of file