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