diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..016ca73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# ===== Python ===== +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ +dist/ +build/ +wheels/ +pip-log.txt +pip-delete-this-directory.txt + +# ===== 가상환경 ===== +.venv/ +venv/ +env/ +ENV/ + +# ===== 테스트 / 캐시 ===== +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +.cache +.mypy_cache/ +.ruff_cache/ + +# ===== 환경 변수 (보안 중요) ===== +.env +.env.* +!.env.example + +# ===== IDE ===== +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# ===== Git ===== +.git/ +.gitignore +.gitattributes + +# ===== 모델 캐시 (런타임에 볼륨으로 처리) ===== +.cache/ +huggingface/ +models/ +*.pt +*.safetensors +*.bin + +# ===== 문서 / 로그 ===== +README.md +docs/ +*.log +logs/ + +# ===== Docker ===== +Dockerfile +.dockerignore +docker-compose*.yml + +# ===== Jupyter / 노트북 ===== +.ipynb_checkpoints/ +*.ipynb + +# ===== 데이터 파일 (BULK 등) ===== +data/ +*.csv +*.json.gz +*.xml +*.zip \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d75c943 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# ===== PostgreSQL ===== +POSTGRES_HOST= +POSTGRES_PORT= +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= + +# ===== OpenSearch ===== +OPENSEARCH_HOST= +OPENSEARCH_PORT= + +# ===== 외부 API ===== +GEMINI_API_KEY= +CLAUDE_API_KEY= +#KIPRIS_API_KEY=실제키 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..fb4ae97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: 🐛 Bug Report +about: 발생한 버그를 제보해주세요. +title: "[BUG] <버그 요약>" +labels: bug +assignees: '' +--- + + + + + + + +## 🐛 버그 설명 + + +## ✅ 기대 동작 + + +## 🔍 해결 방법 +1. +2. +3. + +## 🔗 참고 자료 + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..814bc43 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: ✨ Feature Request +about: 새로운 기능을 제안하세요. +title: "[FEAT] <기능 요약>" +labels: enhancement +assignees: '' +--- + + + + + + + +## 🚀 기능 설명 + + +## 🏆 작업 목록 + +- + +## 🔗 참고 자료 + \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dad82d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ + + + + +## 🛰️ Issue Number + +close # + +## 🪐 작업 내용 + + +## 📚 Reference + +### ✅ Check List +- [ ] 코드가 정상적으로 컴파일되나요? +- [ ] 스웨거 또는 포스트맨에서 결과값을 제대로 확인했나요? +- [ ] 리뷰어 설정을 지정했나요? +- [ ] merge할 브랜치의 위치를 확인했나요? +- [ ] Label을 지정했나요? \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..4dee5d0 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,131 @@ +# ============================================================ +# IPX-AI CD — Docker 빌드 & EC2 배포 +# 흐름: Docker 이미지 빌드 → GHCR 푸시 → EC2 배포 → 헬스체크 +# 주의: BGE-M3 모델 로드로 헬스체크 시간 길게 잡음 (최대 5분) +# ============================================================ + +name: CD — IPX-AI 빌드 & 배포 + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/workflows/ci.yml' + workflow_dispatch: + +concurrency: + group: cd-ipx-ai-production + cancel-in-progress: false + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/ceos-ipx/patent-python + CONTAINER_NAME: patent-python + +jobs: + # ===================================================== + # 1단계: Docker 이미지 빌드 + GHCR 푸시 + # ===================================================== + build-and-push: + name: 빌드 & GHCR 푸시 + runs-on: ubuntu-latest + + outputs: + image-tag: ${{ steps.meta.outputs.sha }} + + steps: + - name: 소스코드 체크아웃 + uses: actions/checkout@v4 + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: GHCR 로그인 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: SHA 단축 태그 생성 + id: meta + run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Docker 이미지 빌드 & 푸시 + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:sha-${{ steps.meta.outputs.sha }} + cache-from: type=gha,scope=ipx-ai + cache-to: type=gha,mode=max,scope=ipx-ai + + # ===================================================== + # 2단계: EC2 배포 + 헬스체크 + # ===================================================== + deploy: + name: EC2 배포 + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: EC2에 SSH 배포 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + cd /opt/ipx + + echo "=== [1/4] 새 이미지 pull ===" + docker compose -f docker-compose.prod.yml --env-file .env.prod pull app-python + + echo "=== [2/4] 컨테이너 재생성 (의존 서비스는 건드리지 않음) ===" + docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-deps app-python + + echo "=== [3/4] 헬스체크 대기 (BGE-M3 로드 시간 고려, 최대 5분) ===" + HEALTHY=false + for i in $(seq 1 60); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' patent-python 2>/dev/null || echo "missing") + echo "시도 $i/60 - 상태: $STATUS" + if [ "$STATUS" = "healthy" ]; then + HEALTHY=true + break + fi + sleep 5 + done + + if [ "$HEALTHY" != "true" ]; then + echo "❌ 헬스체크 실패" + echo "--- 컨테이너 로그 (마지막 150줄) ---" + docker logs patent-python --tail 150 + exit 1 + fi + echo "✅ 헬스체크 통과" + + echo "=== [4/4] 사용하지 않는 이미지 정리 ===" + docker image prune -f + + echo "=== 배포 완료 ===" + + - name: 배포 결과 요약 + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then + echo "✅ IPX-AI Production 배포 성공" + echo "Image: ${{ env.IMAGE_NAME }}:sha-${{ needs.build-and-push.outputs.image-tag }}" + else + echo "❌ IPX-AI Production 배포 실패" + echo "수동 롤백:" + echo " ssh로 EC2 접속, docker-compose.prod.yml의 image 태그를 이전 sha로 변경 후 up -d" + fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9bd386 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +# ============================================================ +# IPX-AI CI — PR 검증 파이프라인 +# ============================================================ + +name: CI — IPX-AI 빌드 검증 + +on: + pull_request: + branches: [ main, dev ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/workflows/cd.yml' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build: + name: 빌드 검증 + runs-on: ubuntu-latest + + steps: + - name: 소스코드 체크아웃 + uses: actions/checkout@v4 + + - name: Python 3.11 설정 + pip 캐시 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: 시스템 의존성 설치 (빌드용) + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends build-essential + + - name: Python 의존성 설치 검증 + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Python 문법 검증 (모든 .py 파일) + run: | + python -m compileall -q app/ + echo "✅ 모든 Python 파일 문법 OK" + + - name: FastAPI 앱 import 검증 + env: + # 실제 외부 서비스 접속을 시도하지 않도록 더미 값 설정 + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_DB: dummy + POSTGRES_USER: dummy + POSTGRES_PASSWORD: dummy + OPENSEARCH_HOST: localhost + OPENSEARCH_PORT: 9200 + GEMINI_API_KEY: dummy + CLAUDE_API_KEY: dummy + KIPRIS_API_KEY: dummy + run: | + # FastAPI 앱이 import 가능한지만 확인 (실제 startup은 실행 안 함) + python -c "from app.main import app; print('✅ FastAPI app import 성공')" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2f908f6..f8e3591 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea/ +data/ +checkpoint*.json # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c3f502a..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/IPX-AI.iml b/.idea/IPX-AI.iml deleted file mode 100644 index 8437fe6..0000000 --- a/.idea/IPX-AI.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 5cb71ef..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 9de2865..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ab77874..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 65bc90e..14a82f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,61 @@ -FROM ubuntu:latest -LABEL authors="SAMSUNG" +# ===== Build stage ===== +FROM python:3.11-slim AS builder -ENTRYPOINT ["top", "-b"] \ No newline at end of file +WORKDIR /app + +# 빌드 시 필요한 시스템 패키지 (gcc 등, ML 패키지 컴파일에 필요) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# 의존성 먼저 설치 (캐시 활용) +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + + +# ===== Runtime stage ===== +FROM python:3.11-slim + +# 런타임 시스템 패키지 (libgomp1: torch/numpy OpenMP 런타임) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 시간대 한국 +ENV TZ=Asia/Seoul + +# non-root 사용자 생성 +RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/false appuser + +WORKDIR /app + +# 빌드 스테이지에서 설치한 Python 패키지 복사 +COPY --from=builder /install /usr/local + +# Python 환경 변수 +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HF_HOME=/app/.cache/huggingface \ + TRANSFORMERS_CACHE=/app/.cache/huggingface + +# 캐시 디렉토리 생성 및 권한 +RUN mkdir -p /app/.cache/huggingface && chown -R appuser:appgroup /app/.cache + +# 소스 코드 복사 +COPY --chown=appuser:appgroup . . + +USER appuser + +EXPOSE 8000 + +# 헬스체크 (Spring 측과 동일한 패턴) +HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Uvicorn 단일 worker (BGE-M3 모델 메모리 부담 고려) +CMD ["uvicorn", "app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--proxy-headers"] \ No newline at end of file diff --git a/README.md b/README.md index 10c3409..641feec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# IPX-AI -IPX AI(Python) server repo +# 동의어 사전 파일 배치 안내 + +## 파일 위치 + +``` +IPX-AI/ +├── app/ +│ └── resources/ +│ └── synonyms_patent.txt +└── ... +``` + +## 원본 위치 + +Spring 레포(`backend-spring/infra/opensearch/synonyms_patent.txt`)의 동일 파일을 그대로 복사합니다. + +각 레포에서의 사용 목적 +1. Spring 레포: OpenSearch 컨테이너에 마운트되어 검색 시 동의어 필터로 활용 +2. Python 레포: 키워드 확장되어 가상 초록 생성 및 KIPRIS API 파라미터에 활용 + +## 동기화 규칙 + +원본은 Spring 레포에 두고, Python 레포에는 복사본을 둡니다. 동의어를 수정할 때는 양쪽 모두 업데이트합니다. + +## 파일 형식 + +```txt +# 주석은 '#'으로 시작 +# 한 줄에 하나의 동의어 그룹, 쉼표로 구분 +# 빈 줄은 무시됨 + +# 단어 수준 동의어 +배터리, 이차전지, secondary cell, 축전지, rechargeable battery +양극재, cathode material, 정극재 + +# 문제-기술 매핑 +급속충전, 정전류정전압충전, 리튬플레이팅억제 +에너지밀도, 고용량양극재, 실리콘음극재 +``` + diff --git a/app/config.py b/app/config.py index e69de29..bc86cae 100644 --- a/app/config.py +++ b/app/config.py @@ -0,0 +1,44 @@ +""" +============================================================ +환경 설정 (config.py) +============================================================ +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) + + # ===== PostgreSQL ===== + postgres_host: str = "localhost" + postgres_port: int = 5432 + postgres_db: str = "patent_db" + postgres_user: str = "ipx_patent_user" + postgres_password: str + + # ===== OpenSearch ===== + opensearch_host: str = "localhost" + opensearch_port: int = 9200 + opensearch_index: str = "patents" + + # ===== 외부 LLM API ===== + gemini_api_key: str + gemini_model: str = "gemini-2.5-flash-lite" + + claude_api_key: str = "" + claude_model: str = "claude-haiku-4-5-20251001" # 성능 안나오면 "claude-sonnet-4-6" + + # # ===== KIPRIS API ===== + # kipris_api_key: str = "" + # kipris_base_url: str = "" + + # ===== 동의어 사전 ===== + synonyms_file_path: str = "app/resources/synonyms_patent.txt" + + +settings = Settings() \ No newline at end of file diff --git a/app/main.py b/app/main.py index e69de29..eb0d102 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,56 @@ +""" +============================================================ +FastAPI 진입점 +============================================================ +서버 시작 시 BGE-M3 모델을 로드한다. +처음 로드 시 ~2GB 다운로드 + 메모리 적재로 수 분 소요될 수 있다. +============================================================ +""" + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.routers import search +from app.services.embedding import embedding_service +from app.services.opensearch_client import opensearch_service + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """서버 시작/종료 시 실행되는 작업""" + # === Startup === + logger.info("[Startup] BGE-M3 모델 로드 시작") + embedding_service.load() + logger.info("[Startup] 초기화 완료, 요청 처리 준비됨") + + yield + + # === Shutdown === + logger.info("[Shutdown] 리소스 정리 시작") + await opensearch_service.close() + logger.info("[Shutdown] 서버 종료") + + +app = FastAPI( + title="IPX-AI Search Server", + lifespan=lifespan, +) + +app.include_router(search.router) + + +@app.get("/health") +def health(): + return { + "status": "UP", + "embedding_loaded": embedding_service.is_loaded, + "embedding_device": embedding_service.device, + } \ No newline at end of file diff --git a/app/resources/synonyms_patent.txt b/app/resources/synonyms_patent.txt new file mode 100644 index 0000000..ebd2253 --- /dev/null +++ b/app/resources/synonyms_patent.txt @@ -0,0 +1,80 @@ +# ============================================================ +# 동의어 및 문제-기술 사전 (B60L 전기자동차/충전 도메인) +# 파일 위치 (OpenSearch): infra/opensearch/synonyms_patent.txt +# 파일 위치 (Python): app/resources/synonyms_patent.txt +# ============================================================ +# 형식 규칙: +# - 한 줄에 하나의 동의어 그룹 +# - 공백 없는 단어만 사용 (다중 단어 영문 토큰 금지) +# - 최소 2개 이상의 단어가 쉼표로 구분 +# - '#'으로 시작하는 줄은 주석 +# - 빈 줄은 무시 +# - 그룹 안의 단어들은 서로 동의어/관련어 +# ============================================================ + + +# ===== 도메인 핵심 어휘 ===== + +전기자동차, 전기차, EV, 친환경차 +배터리, 이차전지, 축전지, 충전지 +리튬이온전지, LIB, 리튬이온이차전지 +충전스테이션, 충전인프라, EVSE +커넥터, 충전건, 충전플러그, 충전소켓, 충전단자, 인렛, 충전포트 +케이블, 전선, 충전케이블 + + +# ===== 무선 충전 ===== + +무선충전, 비접촉식충전, 비접촉충전, 무선전력전송, WPT +자기공명, 공명결합 +유도결합, 자기유도, 전자기유도 +송신코일, 1차코일, 송신부 +수신코일, 2차코일, 수신부 +정렬, 위치정렬, 코일정렬, 위치맞춤 + + +# ===== 배터리 관리 ===== + +BMS, 배터리관리시스템 +정전류정전압, CCCV +급속충전, 고속충전 +완속충전, 일반충전 +SOC, 충전상태, 잔존용량 +SOH, 건강상태, 열화도 +충전효율, 전력효율 + + +# ===== V2X 통신 ===== + +V2X, 차량통신 +V2G, 차량전력망연계 +NFC, 근거리무선 +RFID, 무선식별 + + +# ===== 기타 기술 ===== + +팬터그래프, 집전장치, 집전기 +인버터, 전력변환기 +컨버터, 전력변환 +모터, 전동기 + + +# ===== 문제-기술 매핑 ===== + +충전시간, 충전프로토콜 +배터리수명, 사이클수명, 용량유지율 +열관리, 냉각, 열폭주방지 +안전성, 안전사고예방, 절연 +사용편의성, 사용성, 자동도킹 +충전호환성, 표준화, CCS, CHAdeMO +위치오차, 자동정렬 +열폭주, 셀밸런싱, 과충전방지 + + +# ===== 작용/효과 어휘 ===== + +향상, 개선, 증진, 강화 +저감, 감소, 억제 +보호, 차단 +모니터링, 감시 \ No newline at end of file diff --git a/app/routers/search.py b/app/routers/search.py index e69de29..de50abf 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -0,0 +1,157 @@ +""" +============================================================ +검색 API 라우터 +============================================================ +Spring 서버로부터 검색 요청을 받아 검색 파이프라인을 실행한다. + +검색 파이프라인 (현재 단계까지): + 1. LLM 의도 해석 (Gemini) + 2. 동의어 사전으로 키워드 확장 + 3. HyDE 가상 초록 생성 (Claude) + 4. BGE-M3 임베딩 + 5-1. OpenSearch 키워드 검색 + 5-2. (다음) pgvector 벡터 검색 + 5-3. (다음) KIPRIS API 호출 + 6. (다음) RRF 병합 + 랭킹 + 7. (다음) 추천 이유/요약 생성 +============================================================ +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.services.intent import interpret_intent, IntentResult +from app.services.synonym import synonym_expander +from app.services.hyde import generate_hypothetical_abstract +from app.services.embedding import embedding_service +from app.services.opensearch_client import opensearch_service +from app.services.types import PatentScore + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/search", tags=["search"]) + +# ============================================================ +# 요청/응답 모델 +# ============================================================ + +class SearchRequest(BaseModel): + """Spring → Python 검색 요청""" + + query: str = Field(description="사용자 자연어 입력") + result_count: int = Field(default=10, ge=1, le=50, description="결과 개수 (1~50)") + legal_status: Optional[list[str]] = Field( + default=None, + description="기술 성숙도 필터. 예: ['공개', '등록']" + ) + domain: Optional[str] = Field( + default=None, + description="도메인 (예: '이차전지', '반도체')" + ) + + +class SearchDebugInfo(BaseModel): + """디버깅용 중간 결과 (운영 시 제거 가능)""" + expanded_keywords: list[str] + hypothetical_abstract: str + embedding_dim: int # 무조건 1024 + opensearch_results: list[PatentScore] + + +class SearchResponse(BaseModel): + """Python → Spring 검색 응답""" + + is_valid: bool + reason_invalid: Optional[str] = None + intent: Optional[IntentResult] = None + debug: Optional[SearchDebugInfo] = None + # 이후 단계에서 추가될 필드: + # results: list[PatentResult] = [] + + +# ============================================================ +# 검색 엔드포인트 +# ============================================================ + +@router.post("", response_model=SearchResponse) +async def search(request: SearchRequest) -> SearchResponse: + """ + 검색 파이프라인 진입점 + """ + + # === Step 1: LLM 의도 해석 === + logger.info(f"[검색 요청] query='{request.query}', domain={request.domain}, filters={request.legal_status}") + + try: + intent = await interpret_intent(request.query, domain=request.domain) + except ValueError as e: + logger.warning(f"의도 해석 실패: {e}") + raise HTTPException(status_code=502, detail="검색 의도 해석에 실패했습니다.") + except Exception as e: + logger.exception(f"의도 해석 중 예상치 못한 오류") + raise HTTPException(status_code=500, detail="서버 내부 오류") + + # === 유효하지 않은 입력 처리 === + if not intent.is_valid: + return SearchResponse( + is_valid=False, + reason_invalid=intent.reason_invalid, + intent=intent + ) + + # === 의도 해석 성공 === + logger.info(f"[의도 해석 완료] keywords={intent.keywords}, ipc={intent.ipc_codes}") + + # ===== Step 2: 동의어 사전으로 키워드 확장 ===== + expanded_keywords = synonym_expander.expand(intent.keywords) + logger.info(f"[동의어 확장] {len(intent.keywords)}개 → {len(expanded_keywords)}개") + + # ===== Step 3: HyDE 가상 초록 생성 ===== + try: + hypothetical_abstract = await generate_hypothetical_abstract( + keywords=expanded_keywords, + ipc_codes=intent.ipc_codes + ) + except Exception: + logger.exception("HyDE 가상 초록 생성 실패") + raise HTTPException(status_code=502, detail="가상 초록 생성 실패") + + # ===== Step 4: BGE-M3 임베딩 ===== + try: + query_vector = embedding_service.embed(hypothetical_abstract) + except Exception: + logger.exception("임베딩 생성 실패") + raise HTTPException(status_code=500, detail="임베딩 생성 실패") + + logger.info(f"[임베딩] dim={len(query_vector)}") + + # ===== Step 5-1: OpenSearch 키워드 검색 ===== + # 결과 개수는 RRF를 위해 사용자 요청보다 더 많이 가져옴 + candidate_size = max(request.result_count * 3, 30) + + opensearch_results = await opensearch_service.search( + user_query=request.query, + expanded_keywords=expanded_keywords, + hypothetical_abstract=hypothetical_abstract, + ipc_codes=intent.ipc_codes, + legal_status=request.legal_status, + size=candidate_size, + ) + + # 다음 단계 + # - 병렬 탐색 (pgvector, KIPRIS) + # - RRF 병합 및 랭킹 + # - 추천/요약 생성 (Claude) + + return SearchResponse( + is_valid=True, + intent=intent, + debug=SearchDebugInfo( + expanded_keywords=expanded_keywords, + hypothetical_abstract=hypothetical_abstract, + embedding_dim=len(query_vector), + opensearch_results=opensearch_results, + ) + ) \ No newline at end of file diff --git a/app/services/embedding.py b/app/services/embedding.py index e69de29..8a4b47c 100644 --- a/app/services/embedding.py +++ b/app/services/embedding.py @@ -0,0 +1,107 @@ +""" +============================================================ +BGE-M3 임베딩 서비스 +============================================================ +BGE-M3 모델로 텍스트를 1024차원 벡터로 변환한다. + +서버 시작 시 1회 로드해서 메모리에 유지하여 +검색 요청마다 새로 로드하지 않고 같은 인스턴스를 재사용한다. +============================================================ +""" + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class EmbeddingService: + """BGE-M3 임베딩 모델 래퍼 (싱글톤)""" + + def __init__(self): + self._model = None + self._device: Optional[str] = None + + def load(self) -> "EmbeddingService": + """ + 모델을 메모리에 로드한다. + 최초 호출 시 ~2GB 다운로드 + 로드 (수 분 소요 가능). + 이후엔 캐시에서 빠르게 로드. + """ + if self._model is not None: + logger.info("[임베딩] 이미 로드됨, 재사용") + return self + + from sentence_transformers import SentenceTransformer + import torch + + self._device = "cuda" if torch.cuda.is_available() else "cpu" + logger.info(f"[임베딩] BGE-M3 로딩 중... (device={self._device})") + + self._model = SentenceTransformer("BAAI/bge-m3", device=self._device) + + logger.info("[임베딩] BGE-M3 로드 완료") + return self + + def embed(self, text: str) -> list[float]: + """ + 텍스트 1건을 1024차원 벡터로 변환. + + Args: + text: 임베딩할 텍스트 + + Returns: + 1024차원 float 리스트 (L2 정규화됨 -> 코사인 검색에 바로 사용 가능) + + Raises: + RuntimeError: 모델이 로드되지 않은 상태에서 호출 시 + """ + if self._model is None: + raise RuntimeError("EmbeddingService.load()를 먼저 호출해야 합니다.") + + if not text or not text.strip(): + raise ValueError("빈 텍스트는 임베딩할 수 없습니다.") + + vec = self._model.encode( + text, + normalize_embeddings=True, + show_progress_bar=False + ) + return vec.tolist() + + def embed_batch(self, texts: list[str], batch_size: int = 32) -> list[list[float]]: + """ + 여러 텍스트를 배치로 임베딩 (증분 적재 등에 사용). + + Args: + texts: 임베딩할 텍스트 리스트 + batch_size: 한 번에 처리할 배치 크기 + + Returns: + 각 텍스트의 1024차원 벡터 리스트 + """ + if self._model is None: + raise RuntimeError("EmbeddingService.load()를 먼저 호출해야 합니다.") + + vecs = self._model.encode( + texts, + batch_size=batch_size, + normalize_embeddings=True, + show_progress_bar=False + ) + return vecs.tolist() + + @property + def is_loaded(self) -> bool: + return self._model is not None + + @property + def device(self) -> Optional[str]: + return self._device + + +# ============================================================ +# 싱글톤 인스턴스 +# ============================================================ +# main.py의 lifespan에서 load() 호출 +embedding_service = EmbeddingService() \ No newline at end of file diff --git a/app/services/hyde.py b/app/services/hyde.py index e69de29..468dc99 100644 --- a/app/services/hyde.py +++ b/app/services/hyde.py @@ -0,0 +1,92 @@ +""" +============================================================ +HyDE 서비스 +============================================================ +Claude로 가상 특허 초록을 생성한다. +이 초록은 BGE-M3로 임베딩되어 pgvector 유사도 검색에 사용된다. +============================================================ +""" + +import logging +import httpx +from app.config import settings + +logger = logging.getLogger(__name__) + +CLAUDE_ENDPOINT = "https://api.anthropic.com/v1/messages" + +PROMPT_TEMPLATE = """\ +당신은 특허 명세서 작성 전문가입니다. +다음 정보를 바탕으로 실제 특허처럼 보이는 가상 초록을 작성하세요. + +이 초록은 검색 엔진의 벡터 유사도 비교에 사용되므로, +실제 특허 명세서의 문체와 어휘를 충실히 따라야 합니다. + +## 입력 +- 핵심 키워드: {keywords} +- 관련 IPC 코드: {ipc_codes} + +## 출력 규칙 +- 길이: 200~400자 +- 흐름: 발명의 목적, 해결 수단, 기대 효과가 자연스럽게 이어지도록 +- 키워드를 자연스럽게 녹여 넣되, 단순 나열하지 말 것 +- "본 발명은 ~에 관한 것으로", "본 발명의 목적은" 같은 상투어는 사용하지 말 것 + (실제 데이터는 전처리되어 상투어가 제거되어 있음) +- 다른 설명, 마크다운, 코드블록 없이 초록 본문만 출력 +- 한국어로 작성 + +이제 가상 초록만 출력하세요. +""" + + +async def generate_hypothetical_abstract( + keywords: list[str], + ipc_codes: list[str] +) -> str: + """ + 확장된 키워드와 IPC 코드를 받아 Claude로 가상 초록을 생성한다. + + Args: + keywords: 동의어 확장이 완료된 키워드 리스트 + ipc_codes: LLM이 추정한 IPC 코드 리스트 + + Returns: + 생성된 가상 초록 텍스트 (200~400자) + + Raises: + httpx.HTTPError: Claude API 호출 실패 + ValueError: 응답 파싱 실패 + """ + prompt = PROMPT_TEMPLATE.format( + keywords=", ".join(keywords) if keywords else "(없음)", + ipc_codes=", ".join(ipc_codes) if ipc_codes else "(미상)", + ) + + headers = { + "x-api-key": settings.claude_api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + + payload = { + "model": settings.claude_model, + "max_tokens": 1024, + "temperature": 0.7, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(CLAUDE_ENDPOINT, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + + try: + text = data["content"][0]["text"].strip() + except (KeyError, IndexError) as e: + logger.error(f"Claude 응답 구조 이상: {data}") + raise ValueError(f"Claude 응답 파싱 실패: {e}") + + logger.info(f"[HyDE] 가상 초록 {len(text)}자 생성 완료") + return text \ No newline at end of file diff --git a/app/services/intent.py b/app/services/intent.py index e69de29..1156efa 100644 --- a/app/services/intent.py +++ b/app/services/intent.py @@ -0,0 +1,187 @@ +""" +============================================================ +LLM 의도 해석 서비스 (Gemini 2.5 Flash-Lite) +============================================================ +사용자의 자연어 입력을 분석-> 검색 파이프라인이 사용할 JSON 구조로 변환 + +출력 JSON 예시 +{ + "is_valid": true, + "reason_invalid": null, + "keywords": ["급속충전", "리튬이온", "음극재"], + "ipc_codes": ["H01M 10/052", "H01M 4/13"], +} +============================================================ +""" + +import json +import logging +from typing import Optional +import httpx +from pydantic import BaseModel, Field +from app.config import settings + +logger = logging.getLogger(__name__) + + +# ============================================================ +# Pydantic 모델: LLM 응답 구조 검증 +# ============================================================ + +class IntentResult(BaseModel): + """LLM이 추출한 의도 해석 결과""" + + is_valid: bool = Field(description="특허 검색에 적합한 입력인지") + reason_invalid: Optional[str] = Field(default=None, description="유효하지 않을 때의 사유") + keywords: list[str] = Field(default_factory=list, description="핵심 기술 키워드") + ipc_codes: list[str] = Field(default_factory=list, description="관련 IPC 코드") + + +# ============================================================ +# 프롬프트 구성 +# ============================================================ + +SYSTEM_INSTRUCTION = """\ +당신은 특허 검색을 도와주는 의도 해석 전문가입니다. +사용자의 자연어 입력을 분석해서 특허 검색 파이프라인이 사용할 구조화된 JSON을 생성하세요. + +## 입력 형식 +사용자 메시지에 다음 정보가 포함될 수 있습니다: +- "검색 도메인": 사용자가 미리 선택한 기술 분야 (예: 이차전지, 반도체, AI) +- "사용자 입력": 자연어 검색어 + +도메인이 제공되면 키워드와 IPC 코드 추정 시 해당 도메인에 집중하세요. +도메인이 없으면 사용자 입력만으로 판단하세요. + +## 출력 규칙 + +다음 JSON 형식만 출력하세요. 다른 설명, 마크다운 코드 블록은 절대 포함하지 마세요. + +{ + "is_valid": boolean, + "reason_invalid": string or null, + "keywords": [string], + "ipc_codes": [string] +} + +## 각 필드 작성 가이드 + +- **is_valid**: 다음 중 하나라도 해당되면 false + - 욕설, 잡담, 무의미한 입력 + - 너무 모호해서 검색할 수 없는 입력 (예: "좋은 거", "아무거나") + - 특허와 무관한 주제 (예: "오늘 날씨", "맛집 추천") + - 위 조건에 해당하지 않으면 true + +- **reason_invalid**: is_valid=false일 때만 사용자에게 보여줄 친절한 메시지 작성. true면 null + +- **keywords**: 3~10개의 단어 단위 키워드 + - 기술 키워드: 발명의 핵심 기술 (예: "리튬이온전지", "음극재", "고체전해질") + - 문제 키워드: 사용자가 해결하려는 문제 (예: "급속충전", "배터리수명", "열화") + - 단어 또는 짧은 복합명사 (문장 X) + - 한글/영문 혼용 가능 (예: "LIB", "이차전지") + - 너무 일반적인 단어 제외 (예: "기술", "방법", "장치") + +- **ipc_codes**: 관련 IPC 분류 코드 (메인 그룹 또는 서브 그룹 수준) + - 예: ["H01M 10/052"], ["B60L 53/16", "H02J 7/00"] + - 확실한 것만 1~5개. 모르겠으면 빈 배열 + +## 예시 + +입력: "급속충전 시 배터리 열화를 줄이는 기술" +출력: +{ + "is_valid": true, + "reason_invalid": null, + "keywords": ["급속충전", "리튬이온전지", "배터리수명", "열화", "음극재", "사이클수명"], + "ipc_codes": ["H01M 10/052", "H01M 4/13"] +} + +입력: "전기차 충전 인프라" +출력: +{ + "is_valid": true, + "reason_invalid": null, + "keywords": ["전기차충전", "충전스테이션", "충전기", "커넥터", "전력제어"], + "ipc_codes": ["B60L 53/00", "H02J 7/00"] +} + +입력: "오늘 점심 뭐 먹지" +출력: +{ + "is_valid": false, + "reason_invalid": "특허 검색과 관련된 기술 주제를 입력해 주세요.", + "keywords": [], + "ipc_codes": [] +} +""" + +# ============================================================ +# Gemini API 호출 +# ============================================================ + +GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent" + + +async def interpret_intent(user_query: str, domain: Optional[str] = None) -> IntentResult: + """ + 사용자 자연어 입력을 의도 해석 결과로 변환 + + Args: + user_query: 사용자 자연어 입력 + + Returns: + IntentResult: 의도 해석 결과 + + Raises: + ValueError: LLM 응답을 파싱할 수 없을 때 + httpx.HTTPError: API 호출 실패 시 + """ + + user_text = f"검색 도메인: {domain}\n사용자 입력: {user_query}" if domain else user_query + + payload = { + "system_instruction": { + "parts": [{"text": SYSTEM_INSTRUCTION}] + }, + "contents": [ + { + "role": "user", + "parts": [{"text": user_text}] + } + ], + "generationConfig": { + "temperature": 0.2, + "response_mime_type": "application/json", + "max_output_tokens": 1024 + } + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + GEMINI_ENDPOINT, + params={"key": settings.gemini_api_key}, + json=payload + ) + response.raise_for_status() + data = response.json() + + # Gemini 응답에서 텍스트 추출 + try: + text = data["candidates"][0]["content"]["parts"][0]["text"] + except (KeyError, IndexError) as e: + logger.error(f"Gemini 응답 구조 이상: {data}") + raise ValueError(f"Gemini 응답 파싱 실패: {e}") + + # JSON 파싱 + try: + parsed = json.loads(text) + except json.JSONDecodeError as e: + logger.error(f"JSON 파싱 실패. 응답 텍스트: {text}") + raise ValueError(f"LLM 응답이 유효한 JSON이 아님: {e}") + + # Pydantic 모델로 검증 + try: + return IntentResult(**parsed) + except Exception as e: + logger.error(f"IntentResult 검증 실패. 파싱 결과: {parsed}") + raise ValueError(f"의도 해석 결과 검증 실패: {e}") \ No newline at end of file diff --git a/app/services/opensearch_client.py b/app/services/opensearch_client.py index e69de29..7a30e39 100644 --- a/app/services/opensearch_client.py +++ b/app/services/opensearch_client.py @@ -0,0 +1,225 @@ +""" +============================================================ +OpenSearch 검색 서비스 +============================================================ +BM25 + nori 형태소 분석 + 동의어 확장을 활용한 키워드 검색. + +쿼리 구성 (boost 값은 점차 튜닝해갈 예정!!) + - 제목: 원본 쿼리 (사용자 의도의 핵심이므로 가장 강하게) + - 제목: 키워드 텍스트, boost 3.0 + - 초록: 가상 초록(HyDE), boost 1.5 (동의어 확장 자동) + - 청구항: 키워드 텍스트, boost 1.0 (동의어 확장 자동) + - IPC: prefix 매칭, boost 2.0 (LLM 추정값이므로 IPC의 앞부분만 매칭되도록) + - legal_status: terms 필터 + +응답: + - application_number와 score만 받음 (RRF 병합용) +============================================================ +""" + +import logging +import asyncio +from typing import Optional + +from opensearchpy import OpenSearch + +from app.config import settings +from app.services.types import PatentScore + +logger = logging.getLogger(__name__) + + +class OpenSearchService: + """OpenSearch 비동기 검색 클라이언트""" + + def __init__(self): + self._client: Optional[OpenSearch] = None + + @property + def client(self) -> OpenSearch: + if self._client is None: + self._client = OpenSearch( + hosts=[{ + "host": settings.opensearch_host, + "port": settings.opensearch_port, + }], + http_compress=True, + use_ssl=False, + verify_certs=False, + ssl_show_warn=False, + timeout=30, + max_retries=2, + retry_on_timeout=True, + ) + return self._client + + async def search( + self, + expanded_keywords: list[str], + hypothetical_abstract: str, + user_query: str = None, + ipc_codes: Optional[list[str]] = None, + legal_status: Optional[list[str]] = None, + size: int = 30, + ) -> list[PatentScore]: + """ + OpenSearch에서 키워드 기반 검색을 수행한다. + + Args: + expanded_keywords: 동의어 확장이 완료된 키워드 리스트 + hypothetical_abstract: Claude HyDE 가상 초록 + user_query: 사용자 입력 자연어 + ipc_codes: LLM이 추정한 IPC 코드 + legal_status: 사용자가 선택한 등록상태 + size: 반환할 결과 개수 + + Returns: + 점수 내림차순의 PatentScore 리스트 + """ + query = self._build_query( + user_query=user_query, + expanded_keywords=expanded_keywords, + hypothetical_abstract=hypothetical_abstract, + ipc_codes=ipc_codes, + legal_status=legal_status, + size=size, + ) + + try: + response = await asyncio.to_thread( + self.client.search, + index=settings.opensearch_index, + body=query, + ) + except Exception: + logger.exception("[OpenSearch] 검색 실패") + return [] + + results: list[PatentScore] = [] + for rank, hit in enumerate(response["hits"]["hits"], start=1): + results.append(PatentScore( + application_number=hit["_id"], + score=float(hit["_score"]), + source="opensearch", + rank=rank, + )) + + total = response["hits"]["total"]["value"] + logger.info(f"[OpenSearch] 검색 완료: 반환 {len(results)}건 / 전체 매칭 {total}건") + return results + + def _build_query( + self, + user_query: str, + expanded_keywords: list[str], + hypothetical_abstract: str, + ipc_codes: Optional[list[str]] = None, + legal_status: Optional[list[str]] = None, + size: int = 30, + ) -> dict: + """OpenSearch Query 구조 생성""" + + keyword_text = " ".join(expanded_keywords) if expanded_keywords else "" + + # ===== 텍스트 매칭 (should 절) ===== + should_clauses: list[dict] = [] + + # 원본 쿼리: 사용자 의도의 핵심 신호 (가장 강하게) + if user_query: + should_clauses.append({ + "match": { + "title": { + "query": user_query, + "boost": 6.0, + } + } + }) + + # 제목 매칭 (높은 가중치) + if keyword_text: + should_clauses.append({ + "match": { + "title": { + "query": keyword_text, + "boost": 3.0, + } + } + }) + + # 초록 매칭 (가상 초록 사용 + 동의어 자동 확장) + if hypothetical_abstract: + should_clauses.append({ + "match": { + "abstract_clean": { + "query": hypothetical_abstract, + "boost": 1.5, + } + } + }) + + # 청구항 매칭 + if keyword_text: + should_clauses.append({ + "match": { + "claims_independent": { + "query": keyword_text, + "boost": 1.0, + } + } + }) + + # ===== IPC 코드 매칭 ===== + # LLM 추정 IPC는 100% 정확하지 않으므로 should + boost로 가산점 부여 + # 메인 그룹 prefix만 추출: "H01M 10/052" -> "H01M" + if ipc_codes: + ipc_prefixes = { + code.split(" ")[0].strip() + for code in ipc_codes + if code and code.strip() + } + for prefix in ipc_prefixes: + should_clauses.append({ + "prefix": { + "ipc_codes": { + "value": prefix, + "boost": 2.0, + } + } + }) + + # ===== 정확 일치 필터 ===== + filter_clauses: list[dict] = [] + + # 등록상태: 사용자가 명시적으로 선택한 경우이므로 필터링 + if legal_status: + filter_clauses.append({ + "terms": {"legal_status": legal_status} + }) + + # ===== 최종 쿼리 구성 ===== + query: dict = { + "size": size, + "query": { + "bool": { + "should": should_clauses, + "minimum_should_match": 2, + "filter": filter_clauses, + } + }, + # RRF 병합 단계에서는 ID와 점수만 필요 + "_source": ["application_number"], + } + + return query + + async def close(self) -> None: + """클라이언트 정리 (서버 종료 시 호출)""" + if self._client is not None: + self._client.close() + self._client = None + + +# ============================================================ +# 싱글톤 인스턴스 +# ============================================================ +opensearch_service = OpenSearchService() \ No newline at end of file diff --git a/app/services/synonym.py b/app/services/synonym.py new file mode 100644 index 0000000..19595e9 --- /dev/null +++ b/app/services/synonym.py @@ -0,0 +1,112 @@ +""" +============================================================ +동의어 사전 서비스 +============================================================ +동의어 사전 파일(synonyms_patent.txt)을 파싱해서 키워드를 확장한다. +============================================================ +""" + + +import logging +from pathlib import Path +from typing import Iterable + +logger = logging.getLogger(__name__) + + +class SynonymExpander: + """동의어 사전 기반 키워드 확장기""" + + def __init__(self, dict_path: str): + """ + Args: + dict_path: synonyms_patent.txt 파일 경로 + """ + self.dict_path = dict_path + self._groups: list[set[str]] = [] + self._word_to_group_index: dict[str, set[int]] = {} + self._load() + + def _load(self) -> None: + """사전 파일을 파싱해서 메모리에 로드""" + path = Path(self.dict_path) + if not path.exists(): + logger.warning(f"동의어 사전 파일을 찾을 수 없습니다: {path}") + return + + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + + # 파일 규칙: 빈 줄 또는 주석 건너뜀 + if not line or line.startswith("#"): + continue + + # 파일 규칙: 쉼표로 분리, 정규화 + words = {w.strip().lower() for w in line.split(",")} + words = {w for w in words if w} # 빈 문자열 제거 + + if len(words) < 2: + continue # 동의어 그룹은 최소 2개 + + group_idx = len(self._groups) + self._groups.append(words) + + # 역방향 인덱스 구축 (단어 → 그룹 인덱스 집합) + for word in words: + self._word_to_group_index.setdefault(word, set()).add(group_idx) + + logger.info(f"[동의어 사전] {len(self._groups)}개 그룹 로드 완료") + + def expand(self, keywords: Iterable[str]) -> list[str]: + """ + 키워드 리스트를 받아 동의어/관련어를 추가한 확장 리스트 반환. + + - 원본 키워드는 항상 포함 + - 각 키워드가 속한 동의어 그룹의 모든 단어 추가 + - 중복 제거, 입력 순서 보존 + + Args: + keywords: 원본 키워드 리스트 + + Returns: + 확장된 키워드 리스트 + """ + seen: set[str] = set() + result: list[str] = [] + + for kw in keywords: + kw_norm = kw.strip().lower() + if not kw_norm: + continue + + # 원본 키워드 추가 + if kw_norm not in seen: + seen.add(kw_norm) + result.append(kw.strip()) + + # 동의어 그룹의 다른 단어들 추가 + group_indices = self._word_to_group_index.get(kw_norm, set()) + for idx in group_indices: + for synonym in self._groups[idx]: + if synonym not in seen: + seen.add(synonym) + result.append(synonym) + + return result + + def stats(self) -> dict: + """로드된 사전 통계""" + return { + "groups": len(self._groups), + "unique_words": len(self._word_to_group_index), + } + + +# ============================================================ +# 싱글톤 인스턴스: 서버 시작 시 1회 로드 +# ============================================================ + +from app.config import settings + +synonym_expander = SynonymExpander(settings.synonyms_file_path) \ No newline at end of file diff --git a/app/services/types.py b/app/services/types.py new file mode 100644 index 0000000..c8b790b --- /dev/null +++ b/app/services/types.py @@ -0,0 +1,21 @@ +""" +============================================================ +검색 파이프라인 공통 타입 +: 세 소스(OpenSearch, pgvector, KIPRIS)의 동일한 반환 결과 +============================================================ +""" + +from typing import Literal +from pydantic import BaseModel, Field + + +SourceType = Literal["opensearch", "pgvector", "kipris"] + + +class PatentScore(BaseModel): + """검색 결과 1건의 점수 정보 (RRF 병합 전)""" + + application_number: str = Field(description="특허 출원번호 (공통 식별자)") + score: float = Field(description="해당 소스의 원본 점수") + source: SourceType = Field(description="결과를 만든 소스") + rank: int = Field(description="해당 소스 내 순위 (1부터 시작)") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aa30739..976ba74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ httpx python-dotenv pydantic pydantic-settings -apscheduler \ No newline at end of file +apscheduler +pandas \ No newline at end of file diff --git a/scripts/initial_ingestion.py b/scripts/initial_ingestion.py index e69de29..303860c 100644 --- a/scripts/initial_ingestion.py +++ b/scripts/initial_ingestion.py @@ -0,0 +1,579 @@ +""" +============================================================ +KIPRIS BULK 초기 적재 파이프라인 (Google Colab용) +Colab은 내 로컬 컴퓨터(127.0.0.1) 보지 못하는 관계로 Python 스크립트로 실행 (FastAPI 서버 필요 X) +============================================================ +실행 순서 + 1. KIPRIS BULK 압축 해제 (TXT 폴더에 6개 파일) + 2. 6개 .txt 파일 파싱 및 출원번호 기준 병합 + 3. 텍스트 전처리 (XML 태그/상투어/노이즈 제거) + 4. BGE-M3 임베딩 생성 (Colab GPU) + 5. OpenSearch + pgvector 적재 + 6. sync_history 기록 +============================================================ +""" + +# ===== 0. 라이브러리 설치 (Colab 최초 1회) ===== +# !pip install sentence-transformers opensearch-py psycopg2-binary pandas + +import re +import json +import unicodedata +import pandas as pd +from datetime import datetime, date +from pathlib import Path + +# ===== 설정 ===== +BULK_DIR = "./data/bulk/TXT" # 압축 해제된 TXT 폴더 경로 +DELIMITER = "¶" # KIPRIS BULK 반환 .txt 파일 구분자 +ENCODING = "utf-8" # 인코딩 + +POSTGRES_HOST = "localhost" # Python 스크립트로 실행하므로 로컬 호스트 가능 +POSTGRES_PORT = 5432 +POSTGRES_DB = "patent_db" +POSTGRES_USER = "ipx_patent_user" +POSTGRES_PASSWORD = "ipx_patent_password" + +OPENSEARCH_HOST = "localhost" # Python 스크립트로 실행하므로 +OPENSEARCH_PORT = 9200 +OPENSEARCH_INDEX = "patents" + +BATCH_SIZE = 100 # 임베딩 배치 크기 + + +# ============================================================ +# 1. KIPRIS BULK 파일 파싱 +# ============================================================ + +def read_bulk_file(filename: str) -> pd.DataFrame: + """KIPRIS BULK txt 파일을 pandas DataFrame으로 읽기""" + filepath = Path(BULK_DIR) / filename + df = pd.read_csv( + filepath, + sep=DELIMITER, + encoding=ENCODING, + dtype=str, # 모든 컬럼 문자열로 읽기 (출원번호 앞자리 0 보존) + keep_default_na=False, # 빈 문자열을 NaN으로 변환하지 않음 + engine="python" # 1자 외 구분자는 python 엔진 필요 + ) + df.columns = df.columns.str.strip() + return df + + +def parse_bibliographic() -> pd.DataFrame: + """서지정보 파싱""" + df = read_bulk_file("Bibliographic.txt") + + # 필요한 컬럼만 추출 및 이름 정리 + result = pd.DataFrame({ + "application_number": df["출원번호"], + "registration_number": df["등록번호"], + "open_number": df["공개번호"], + "application_date": df["출원일자"].apply(parse_date), + "open_date": df["공개일자"].apply(parse_date), + "registration_date": df["등록일자"].apply(parse_date), + "title": df["발명의명칭(국문)"].str.strip(), + "title_en": df["발명의명칭(영문)"].str.strip(), + "legal_status": df["등록사항"].str.strip(), # 공개/등록/소멸/취하/거절 + }) + return result + + +def parse_abstract() -> pd.DataFrame: + """초록 파싱 (XML 태그 제거)""" + df = read_bulk_file("Abstract.txt") + df["abstract_raw"] = df["초록"].apply(strip_xml_tags) + return df[["출원번호", "abstract_raw"]].rename(columns={"출원번호": "application_number"}) + + +def parse_claim() -> pd.DataFrame: + """청구항 파싱 (XML 태그 제거 + 독립 청구항 추출)""" + df = read_bulk_file("Claim.txt") + df["claims_independent"] = df["청구항"].apply(extract_independent_claims) + return df[["출원번호", "claims_independent"]].rename(columns={"출원번호": "application_number"}) + + +def parse_ipc() -> pd.DataFrame: + """IPC 코드 파싱 (출원번호당 여러 코드 → 리스트)""" + df = read_bulk_file("IPC.txt") + df["ipc코드"] = df["ipc코드"].str.strip() + ipc_grouped = df.groupby("출원번호")["ipc코드"].apply(list).reset_index() + return ipc_grouped.rename(columns={"출원번호": "application_number", "ipc코드": "ipc_codes"}) + + +def parse_cpc() -> pd.DataFrame: + """CPC 코드 파싱 (출원번호당 여러 코드 → 리스트)""" + df = read_bulk_file("CPC.txt") + df["cpc코드"] = df["cpc코드"].str.strip() + cpc_grouped = df.groupby("출원번호")["cpc코드"].apply(list).reset_index() + return cpc_grouped.rename(columns={"출원번호": "application_number", "cpc코드": "cpc_codes"}) + + +def parse_related_person() -> pd.DataFrame: + """관련인 파싱 (출원인, 발명자만 추출)""" + df = read_bulk_file("RelatedPerson.txt") + df["성명"] = df["성명"].str.strip() + df["관련인구분"] = df["관련인구분"].str.strip() + + # 출원인만 필터링 + applicants = df[df["관련인구분"].str.startswith("출원인")] + applicants_grouped = applicants.groupby("출원번호")["성명"].apply( + lambda x: ", ".join(x.dropna()) + ).reset_index() + applicants_grouped.columns = ["application_number", "applicant_name"] + + # 발명자만 필터링 + inventors = df[df["관련인구분"].str.startswith("발명자")] + inventors_grouped = inventors.groupby("출원번호")["성명"].apply( + lambda x: ", ".join(x.dropna()) + ).reset_index() + inventors_grouped.columns = ["application_number", "inventor_name"] + + return applicants_grouped.merge(inventors_grouped, on="application_number", how="outer") + + +# ============================================================ +# 2. 텍스트 전처리 +# ============================================================ + +# XML/HTML 태그 패턴 +XML_TAG_PATTERN = re.compile(r"<[^>]+>") +NEWLINE_TAG_PATTERN = re.compile(r"", re.IGNORECASE) + +# 상투어 패턴 +BOILERPLATE_PATTERNS = [ + re.compile(r"본\s*발명은\s*"), + re.compile(r"본\s*고안은\s*"), + re.compile(r"에\s*관한\s*것으로[서,.]?\s*"), + re.compile(r"에\s*관한\s*것이다[.]?\s*"), + re.compile(r"상기\s*구성에\s*의하면\s*"), + re.compile(r"이하[,]?\s*첨부된?\s*도면을?\s*참조하여\s*상세히\s*설명한다[.]?\s*"), + re.compile(r"것을\s*특징으로\s*하는\s*"), +] + +# 노이즈 패턴 +NOISE_PATTERNS = [ + re.compile(r"\[\d{4}\]"), # 문단번호 + re.compile(r"도\s*\d+[a-zA-Z]?"), # 도면 참조 + re.compile(r"FIG\.\s*\d+", re.IGNORECASE), + re.compile(r"\(주\)"), + re.compile(r"\(주식회사\)"), + re.compile(r"[※■▶▷●○◆◇]"), +] + +# 종속 청구항 판별 패턴 +DEPENDENT_PATTERN = re.compile(r"제\s*\d+\s*항에\s*있어서") + + +def strip_xml_tags(text: str) -> str: + """XML/HTML 태그 제거""" + if not text or pd.isna(text): + return "" + #
태그는 공백으로 치환 + text = NEWLINE_TAG_PATTERN.sub(" ", text) + # 나머지 태그 모두 제거 + text = XML_TAG_PATTERN.sub("", text) + return text.strip() + + +def clean_text(text: str) -> str: + """초록/청구항 전처리""" + if not text: + return "" + + # 1. 유니코드 정규화 (전각→반각) + text = unicodedata.normalize("NFKC", text) + + # 2. 상투어 제거 + for pattern in BOILERPLATE_PATTERNS: + text = pattern.sub("", text) + + # 3. 노이즈 제거 + for pattern in NOISE_PATTERNS: + text = pattern.sub("", text) + + # 4. 연속 공백 정리 + text = re.sub(r"\s+", " ", text).strip() + + return text + + +def extract_independent_claims(claims_text: str) -> str: + """청구항 XML에서 독립 청구항만 추출""" + if not claims_text or pd.isna(claims_text): + return "" + + # 각 블록 추출 + claim_blocks = re.findall( + r'(.*?)', + claims_text, + re.DOTALL + ) + + independent = [] + for num, content in claim_blocks: + # 삭제 표시된 청구항 제외 + if '' in content: + continue + + # 내부 텍스트만 추출 + text = strip_xml_tags(content) + + # 종속 청구항 제외 + if DEPENDENT_PATTERN.search(text): + continue + + independent.append(text) + + return " ".join(independent) + + +def parse_date(date_str: str) -> str | None: + """날짜 문자열 정규화 → 'YYYY-MM-DD'""" + if not date_str or pd.isna(date_str): + return None + date_str = str(date_str).strip() + if not date_str: + return None + # 'YYYYMMDD' → 'YYYY-MM-DD' + if len(date_str) == 8 and date_str.isdigit(): + return f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" + # 이미 'YYYY-MM-DD' 형식이면 그대로 + try: + datetime.strptime(date_str[:10], "%Y-%m-%d") + return date_str[:10] + except ValueError: + return None + + +# ============================================================ +# 3. 전체 병합 +# ============================================================ + +def merge_all_files() -> pd.DataFrame: + """6개 파일을 출원번호 기준으로 병합""" + print("[병합] Bibliographic.txt 읽기") + biblio = parse_bibliographic() + print(f" - {len(biblio)}건") + + print("[병합] Abstract.txt 읽기") + abstract = parse_abstract() + print(f" - {len(abstract)}건") + + print("[병합] Claim.txt 읽기") + claim = parse_claim() + print(f" - {len(claim)}건") + + print("[병합] IPC.txt 읽기") + ipc = parse_ipc() + print(f" - {len(ipc)}건") + + print("[병합] CPC.txt 읽기") + cpc = parse_cpc() + print(f" - {len(cpc)}건") + + print("[병합] RelatedPerson.txt 읽기") + related = parse_related_person() + print(f" - {len(related)}건") + + # 서지정보를 기준으로 left join (서지에 없는 출원번호는 무시) + merged = ( + biblio + .merge(abstract, on="application_number", how="left") + .merge(claim, on="application_number", how="left") + .merge(ipc, on="application_number", how="left") + .merge(cpc, on="application_number", how="left") + .merge(related, on="application_number", how="left") + ) + + # 리스트 컬럼 NaN → 빈 리스트 + merged["ipc_codes"] = merged["ipc_codes"].apply(lambda x: x if isinstance(x, list) else []) + merged["cpc_codes"] = merged["cpc_codes"].apply(lambda x: x if isinstance(x, list) else []) + + print(f"[병합 완료] 총 {len(merged)}건") + return merged + + +# ============================================================ +# 4. 전처리 적용 +# ============================================================ + +def preprocess_dataframe(df: pd.DataFrame) -> pd.DataFrame: + """전체 DataFrame에 전처리 적용""" + print("[전처리] 초록/청구항 정제") + + df["abstract_clean"] = df["abstract_raw"].apply(clean_text) + df["claims_independent"] = df["claims_independent"].apply(clean_text) + + # 임베딩 원본: "제목. 전처리된 초록" + df["embedding_text"] = df.apply( + lambda row: f"{row['title']}. {row['abstract_clean']}", + axis=1 + ) + + # 초록이 비어있는 행 제외 + before = len(df) + df = df[df["abstract_clean"].str.len() > 0].reset_index(drop=True) + print(f"[전처리 완료] 유효 특허: {len(df)}건 (제외: {before - len(df)}건)") + + return df + + +# ============================================================ +# 5. BGE-M3 임베딩 +# ============================================================ + +def load_embedding_model(): + """BGE-M3 모델 로드""" + from sentence_transformers import SentenceTransformer + import torch + + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"[임베딩] 디바이스: {device}") + + model = SentenceTransformer("BAAI/bge-m3", device=device) + print("[임베딩] BGE-M3 로드 완료") + return model + + +def generate_embeddings(model, df: pd.DataFrame) -> pd.DataFrame: + """배치 단위로 임베딩 생성""" + texts = df["embedding_text"].tolist() + all_embeddings = [] + + for i in range(0, len(texts), BATCH_SIZE): + batch = texts[i:i + BATCH_SIZE] + embeddings = model.encode( + batch, + normalize_embeddings=True, + show_progress_bar=False + ) + all_embeddings.extend(embeddings.tolist()) + + if (i // BATCH_SIZE + 1) % 10 == 0: + print(f" 임베딩 진행: {min(i + BATCH_SIZE, len(texts))} / {len(texts)}") + + df["embedding"] = all_embeddings + print(f"[임베딩 완료] {len(df)}건") + return df + + +# ============================================================ +# 6. OpenSearch 적재 +# ============================================================ + +def create_opensearch_client(): + from opensearchpy import OpenSearch + client = OpenSearch( + hosts=[{"host": OPENSEARCH_HOST, "port": OPENSEARCH_PORT}], + http_compress=True, + use_ssl=False, + verify_certs=False, + ssl_show_warn=False, + timeout=60, # 추가: 60초로 늘림 + max_retries=3, # 추가: 자동 재시도 + ) + print(f"[OpenSearch] 연결: {client.info()['version']['number']}") + return client + + +def bulk_index_opensearch(client, df: pd.DataFrame): + """OpenSearch Bulk 적재""" + from opensearchpy.helpers import bulk + + def clean_value(v): + """NaN, None, 빈 공백을 None으로 정규화""" + if v is None: + return None + if isinstance(v, float) and pd.isna(v): + return None + if isinstance(v, str) and not v.strip(): + return None + return v + + def generate_actions(): + for _, row in df.iterrows(): + doc = { + "_index": OPENSEARCH_INDEX, + "_id": str(row["application_number"]), + "application_number": str(row["application_number"]), + "registration_number": clean_value(row.get("registration_number")), + "open_number": clean_value(row.get("open_number")), + "application_date": clean_value(row.get("application_date")), + "open_date": clean_value(row.get("open_date")), + "registration_date": clean_value(row.get("registration_date")), + "title": row["title"], + "abstract_clean": row["abstract_clean"], + "claims_independent": clean_value(row.get("claims_independent")), + "applicant_name": clean_value(row.get("applicant_name")) or "", + "inventor_name": clean_value(row.get("inventor_name")) or "", + "ipc_codes": row.get("ipc_codes", []), + "cpc_codes": row.get("cpc_codes", []), + "legal_status": row.get("legal_status", ""), + "collected_date": date.today().isoformat(), + } + # None 값 제거 (OpenSearch에 NaN 안 들어가게) + doc = {k: v for k, v in doc.items() if v is not None or k == "_index" or k == "_id"} + yield doc + + success, errors = bulk(client, generate_actions(), chunk_size=500) + print(f"[OpenSearch] 적재 완료: 성공 {success}건, 실패 {len(errors)}건") + if errors: + print(f" 에러 샘플: {errors[:3]}") + + +# ============================================================ +# 7. pgvector 적재 +# ============================================================ + +def create_pgvector_connection(): + import psycopg2 + conn = psycopg2.connect( + host=POSTGRES_HOST, + port=POSTGRES_PORT, + dbname=POSTGRES_DB, + user=POSTGRES_USER, + password=POSTGRES_PASSWORD, + ) + print("[pgvector] PostgreSQL 연결 완료") + return conn + + +def bulk_insert_pgvector(conn, df: pd.DataFrame): + """pgvector Bulk 적재""" + import psycopg2.extras + + cursor = conn.cursor() + values = [] + + for _, row in df.iterrows(): + values.append(( + row["application_number"], + row["title"], + row["abstract_clean"], + row["embedding"], + row.get("ipc_codes", []), + row.get("legal_status", ""), + row.get("application_date"), + )) + + psycopg2.extras.execute_batch( + cursor, + """ + INSERT INTO patent_vectors + (application_number, title, abstract_clean, embedding, ipc_codes, legal_status, application_date) + VALUES + (%s, %s, %s, %s::vector, %s, %s, %s) + ON CONFLICT (application_number) DO NOTHING + """, + values, + page_size=500, + ) + conn.commit() + cursor.close() + print(f"[pgvector] 적재 완료: {len(values)}건") + + +def create_hnsw_index(conn): + """HNSW 벡터 인덱스 생성 (적재 완료 후)""" + cursor = conn.cursor() + print("[pgvector] HNSW 인덱스 생성 중...") + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_pv_hnsw ON patent_vectors + USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 200); + """) + conn.commit() + cursor.close() + print("[pgvector] HNSW 인덱스 생성 완료") + + +# ============================================================ +# 8. sync_history 기록 +# ============================================================ + +def insert_sync_history(conn, count: int): + """수집 이력 저장""" + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO sync_history (job_type, query_date_to, records_added, status) + VALUES (%s, %s, %s, %s) + """, + ("initial", date.today(), count, "success") + ) + conn.commit() + cursor.close() + print(f"[sync_history] 초기 적재 이력 기록: {count}건") + + +# ============================================================ +# 9. 중간 저장 (세션 끊김 대비) +# ============================================================ + +def save_checkpoint(df: pd.DataFrame, filename: str = "checkpoint.json"): + """임베딩 결과를 파일로 저장""" + df.to_json(filename, orient="records", force_ascii=False) + print(f"[체크포인트] {len(df)}건 저장 → {filename}") + + +def load_checkpoint(filename: str = "checkpoint.json") -> pd.DataFrame: + """저장된 체크포인트 복원""" + df = pd.read_json(filename, orient="records") + print(f"[체크포인트] {len(df)}건 복원 ← {filename}") + return df + + +# ============================================================ +# 10. 메인 실행 +# ============================================================ + +def run_initial_ingestion(): + """초기 적재 전체 파이프라인 실행""" + + print("\n" + "=" * 60) + print("STEP 1: BULK 파일 파싱 및 병합") + print("=" * 60) + df = merge_all_files() + + print("\n" + "=" * 60) + print("STEP 2: 텍스트 전처리") + print("=" * 60) + df = preprocess_dataframe(df) + + print("\n" + "=" * 60) + print("STEP 3: BGE-M3 임베딩 생성") + print("=" * 60) + model = load_embedding_model() + df = generate_embeddings(model, df) + + # 중간 저장 + save_checkpoint(df, "checkpoint_embedded.json") + + print("\n" + "=" * 60) + print("STEP 4: OpenSearch 적재") + print("=" * 60) + os_client = create_opensearch_client() + bulk_index_opensearch(os_client, df) + + print("\n" + "=" * 60) + print("STEP 5: pgvector 적재") + print("=" * 60) + pg_conn = create_pgvector_connection() + bulk_insert_pgvector(pg_conn, df) + create_hnsw_index(pg_conn) + insert_sync_history(pg_conn, len(df)) + pg_conn.close() + + print("\n" + "=" * 60) + print(f"[완료] 총 {len(df)}건 적재 완료") + print("=" * 60) + + +# ===== 실행 방식 택 ===== +# 1. Colab에서 아래 한 줄 실행 +# run_initial_ingestion() + +# 2. Python 스크립트에서 아래 실행 +if __name__ == "__main__": + run_initial_ingestion() \ No newline at end of file