This commit is contained in:
엔큐 2025-10-13 08:29:41 +09:00
parent d7d57b0327
commit bc4aacea62
4 changed files with 412 additions and 27 deletions

View File

@ -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())

View File

@ -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:
"""답변과 문서 내용의 직접적 연관성 검증"""

View File

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 노란색 원형 배경 -->
<circle cx="16" cy="16" r="16" fill="#FFD700"/>
<!-- 흰색 내부 원 (도넛 모양) -->
<circle cx="16" cy="16" r="12" fill="white"/>
<!-- 중앙에 W 문자 -->
<text x="16" y="20" font-family="Arial, sans-serif" font-size="14" font-weight="bold" text-anchor="middle" fill="#FFD700">W</text>
</svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@ -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<SimpleMarkdownRendererProps> = ({
content,
detailedReferences = [],
onReferenceClick
}) => {
// 마크다운 렌더링 함수
const renderMarkdown = (text: string): React.ReactNode => {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 일반 링크 처리
link: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">
{children}
</a>
),
// 헤딩 스타일
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-gray-900 mb-4 mt-6 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold text-gray-800 mb-3 mt-5">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-medium text-gray-700 mb-2 mt-4">
{children}
</h3>
),
// 문단 스타일
p: ({ children }) => (
<p className="text-gray-700 leading-relaxed mb-4">
{children}
</p>
),
// 리스트 스타일
ul: ({ children }) => (
<ul className="list-disc pl-6 text-gray-700 mb-4 space-y-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-6 text-gray-700 mb-4 space-y-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-gray-700 leading-relaxed ml-2">
{children}
</li>
),
// 강조 스타일
strong: ({ children }) => (
<strong className="font-semibold text-gray-900">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-gray-800">
{children}
</em>
),
// 코드 스타일
code: ({ children }) => (
<code className="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="bg-gray-100 text-gray-800 p-4 rounded-lg overflow-x-auto mb-4">
{children}
</pre>
),
// 인용 스타일
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-6 py-2 italic text-gray-600 mb-4 bg-blue-50 rounded-r">
{children}
</blockquote>
),
// 테이블 스타일
table: ({ children }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full border-collapse border border-gray-300">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-gray-300 bg-gray-100 px-4 py-2 text-left font-semibold text-gray-900">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-gray-300 px-4 py-2 text-gray-700">
{children}
</td>
),
}}
>
{text}
</ReactMarkdown>
);
};
return (
<div className="prose prose-gray max-w-none">
{renderMarkdown(content)}
</div>
);
};
export default SimpleMarkdownRenderer;