From 02abbb210c0a81a91cc634e0383ec9192c0e0d92 Mon Sep 17 00:00:00 2001 From: taeik Date: Fri, 29 May 2026 12:46:44 +0900 Subject: [PATCH 01/16] =?UTF-8?q?chore:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 15 +++++++++++++++ app/config.py | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aac53d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# ===== PostgreSQL (Spring 레포 docker-compose가 띄운 인프라에 접속) ===== +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/app/config.py b/app/config.py index e69de29..6f3e975 100644 --- a/app/config.py +++ b/app/config.py @@ -0,0 +1,25 @@ +# app/config.py +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # 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 + + # 외부 API + # gemini_api_key: str + # claude_api_key: str + # kipris_api_key: str + + +settings = Settings() \ No newline at end of file From 11d43705597ca2d2d5e06d8b03efb7d2f49bb452 Mon Sep 17 00:00:00 2001 From: taeik Date: Fri, 29 May 2026 16:18:56 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=ED=8A=B9=ED=97=88=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + requirements.txt | 3 +- scripts/initial_ingestion.py | 579 +++++++++++++++++++++++++++++++++++ 3 files changed, 582 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2f908f6..658f31e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ +data/ # Byte-compiled / optimized / DLL files __pycache__/ 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 From 5254cda78f4615fb384f547562480c2896975454 Mon Sep 17 00:00:00 2001 From: taeik Date: Sat, 30 May 2026 12:39:43 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20LLM=EC=9D=84=20=ED=86=B5=ED=95=9C?= =?UTF-8?q?=20=EC=9D=98=EB=8F=84=ED=95=B4=EC=84=9D=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .idea/.gitignore | 8 - .idea/IPX-AI.iml | 8 - .idea/inspectionProfiles/Project_Default.xml | 6 - .../inspectionProfiles/profiles_settings.xml | 6 - .idea/misc.xml | 7 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - app/config.py | 32 +++- app/main.py | 25 +++ app/routers/search.py | 101 +++++++++++ app/services/intent.py | 166 ++++++++++++++++++ 12 files changed, 317 insertions(+), 57 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/IPX-AI.iml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 658f31e..f8e3591 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +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/app/config.py b/app/config.py index 6f3e975..82b999a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,25 +1,41 @@ -# app/config.py +""" +============================================================ +환경 설정 (config.py) +============================================================ +""" + from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) - # PostgreSQL + # ===== 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 ===== 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" - # 외부 API - # gemini_api_key: str - # claude_api_key: str - # kipris_api_key: str + # claude_api_key: str = "" + # claude_model: str = "claude-sonnet-4-20250514" + # + # # ===== KIPRIS API ===== + # kipris_api_key: str = "" + # kipris_base_url: str = "" settings = Settings() \ No newline at end of file diff --git a/app/main.py b/app/main.py index e69de29..376cb51 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,25 @@ +""" +============================================================ +FastAPI 진입점 +============================================================ +""" + +import logging +from fastapi import FastAPI +from app.routers import search + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) + +app = FastAPI(title="IPX-AI Search Server") + +# 라우터 등록 +app.include_router(search.router) + + +@app.get("/health") +def health(): + return {"status": "UP"} \ No newline at end of file diff --git a/app/routers/search.py b/app/routers/search.py index e69de29..c1fd7a2 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -0,0 +1,101 @@ +""" +============================================================ +검색 API 라우터 +============================================================ +Spring 서버로부터 검색 요청을 받아 검색 파이프라인을 실행한다. + +엔드포인트: + POST /search +============================================================ +""" + +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.services.intent import interpret_intent, IntentResult + +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 SearchResponse(BaseModel): + """Python → Spring 검색 응답""" + + is_valid: bool + reason_invalid: Optional[str] = None + intent: Optional[IntentResult] = None + # 이후 단계에서 추가될 필드: + # results: list[PatentResult] = [] + + +# ============================================================ +# 검색 엔드포인트 +# ============================================================ + +@router.post("", response_model=SearchResponse) +async def search(request: SearchRequest) -> SearchResponse: + """ + 검색 파이프라인 진입점 + + 1. LLM 의도 해석 + 2. (다음 단계) 가상 초록 생성 + HyDE 임베딩 + 3. (다음 단계) OpenSearch + pgvector + KIPRIS 병렬 탐색 + 4. (다음 단계) RRF 병합 및 랭킹 + 5. (다음 단계) LLM 추천/요약 생성 + """ + + # === Step 1: LLM 의도 해석 === + logger.info(f"[검색 요청] query='{request.query}', filters={request.legal_status}") + + try: + intent = await interpret_intent(request.query) + 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}") + + # 다음 단계 + # - HyDE 가상 초록 생성 (Claude) + # - BGE-M3 임베딩 + # - 병렬 탐색 (OpenSearch, pgvector, KIPRIS) + # - RRF 병합 및 랭킹 + # - 추천/요약 생성 (Claude) + + return SearchResponse( + is_valid=True, + intent=intent + ) \ No newline at end of file diff --git a/app/services/intent.py b/app/services/intent.py index e69de29..77c1718 100644 --- a/app/services/intent.py +++ b/app/services/intent.py @@ -0,0 +1,166 @@ +""" +============================================================ +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을 생성하세요. + +## 출력 규칙 + +다음 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) -> IntentResult: + """ + 사용자 자연어 입력을 의도 해석 결과로 변환 + """ + payload = { + "system_instruction": { + "parts": [{"text": SYSTEM_INSTRUCTION}] + }, + "contents": [ + { + "role": "user", + "parts": [{"text": user_query}] + } + ], + "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 From c09caea63255776f1000b7647e735e8b6e0565d1 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 19:07:05 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=EB=8F=99=EC=9D=98=EC=96=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=ED=8C=8C=EC=8B=B1=20=EB=B0=8F=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=ED=99=95=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 ++++++++++- app/resources/synonyms_patent.txt | 0 app/services/synonym.py | 112 ++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 app/resources/synonyms_patent.txt create mode 100644 app/services/synonym.py 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/resources/synonyms_patent.txt b/app/resources/synonyms_patent.txt new file mode 100644 index 0000000..e69de29 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 From a0c0b8dbd5161d203e2764d54c569932e8b4be62 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 19:54:01 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20LLM=EC=9D=84=20=ED=86=B5=ED=95=9C?= =?UTF-8?q?=20=EA=B0=80=EC=83=81=EC=B4=88=EB=A1=9D=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +-- app/config.py | 9 +++-- app/services/hyde.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index aac53d3..d75c943 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ===== PostgreSQL (Spring 레포 docker-compose가 띄운 인프라에 접속) ===== +# ===== PostgreSQL ===== POSTGRES_HOST= POSTGRES_PORT= POSTGRES_DB= @@ -10,6 +10,6 @@ OPENSEARCH_HOST= OPENSEARCH_PORT= # ===== 외부 API ===== -#GEMINI_API_KEY=실제키 -#CLAUDE_API_KEY=실제키 +GEMINI_API_KEY= +CLAUDE_API_KEY= #KIPRIS_API_KEY=실제키 \ No newline at end of file diff --git a/app/config.py b/app/config.py index 82b999a..bc86cae 100644 --- a/app/config.py +++ b/app/config.py @@ -30,12 +30,15 @@ class Settings(BaseSettings): gemini_api_key: str gemini_model: str = "gemini-2.5-flash-lite" - # claude_api_key: str = "" - # claude_model: str = "claude-sonnet-4-20250514" - # + 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/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 From b3661083e463e2525bc95551fcde889c9dc2aad2 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 19:55:44 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20BGE-M3=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/embedding.py | 107 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) 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 From a65d26918ea0ea956968b393bcc41fcfdd87f904 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 20:00:29 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=97=90=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=9E=84?= =?UTF-8?q?=EB=B2=A0=EB=94=A9=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 36 ++++++++++++++++++++++--- app/routers/search.py | 61 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index 376cb51..77ae358 100644 --- a/app/main.py +++ b/app/main.py @@ -2,24 +2,52 @@ ============================================================ 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 -# 로깅 설정 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] 초기화 완료, 요청 처리 준비됨") -app = FastAPI(title="IPX-AI Search Server") + yield + + # === Shutdown === + 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"} \ No newline at end of file + return { + "status": "UP", + "embedding_loaded": embedding_service.is_loaded, + "embedding_device": embedding_service.device, + } \ No newline at end of file diff --git a/app/routers/search.py b/app/routers/search.py index c1fd7a2..e22942b 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -4,22 +4,31 @@ ============================================================ Spring 서버로부터 검색 요청을 받아 검색 파이프라인을 실행한다. -엔드포인트: - POST /search +검색 파이프라인 (현재 단계까지): + 1. LLM 의도 해석 (Gemini) + 2. 동의어 사전으로 키워드 확장 + 3. HyDE 가상 초록 생성 (Claude) + 4. BGE-M3 임베딩 + 5. (다음) OpenSearch + pgvector + KIPRIS 병렬 탐색 + 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 logger = logging.getLogger(__name__) router = APIRouter(prefix="/search", tags=["search"]) - # ============================================================ # 요청/응답 모델 # ============================================================ @@ -39,12 +48,20 @@ class SearchRequest(BaseModel): ) +class SearchDebugInfo(BaseModel): + """디버깅용 중간 결과 (운영 시 제거 가능)""" + expanded_keywords: list[str] + hypothetical_abstract: str + embedding_dim: int #1024 + + class SearchResponse(BaseModel): """Python → Spring 검색 응답""" is_valid: bool reason_invalid: Optional[str] = None intent: Optional[IntentResult] = None + debug: Optional[SearchDebugInfo] = None # 이후 단계에서 추가될 필드: # results: list[PatentResult] = [] @@ -57,12 +74,6 @@ class SearchResponse(BaseModel): async def search(request: SearchRequest) -> SearchResponse: """ 검색 파이프라인 진입점 - - 1. LLM 의도 해석 - 2. (다음 단계) 가상 초록 생성 + HyDE 임베딩 - 3. (다음 단계) OpenSearch + pgvector + KIPRIS 병렬 탐색 - 4. (다음 단계) RRF 병합 및 랭킹 - 5. (다음 단계) LLM 추천/요약 생성 """ # === Step 1: LLM 의도 해석 === @@ -88,14 +99,40 @@ async def search(request: SearchRequest) -> SearchResponse: # === 의도 해석 성공 === 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)}") + # 다음 단계 - # - HyDE 가상 초록 생성 (Claude) - # - BGE-M3 임베딩 # - 병렬 탐색 (OpenSearch, pgvector, KIPRIS) # - RRF 병합 및 랭킹 # - 추천/요약 생성 (Claude) return SearchResponse( is_valid=True, - intent=intent + intent=intent, + debug=SearchDebugInfo( + expanded_keywords=expanded_keywords, + hypothetical_abstract=hypothetical_abstract, + embedding_dim=len(query_vector), + ) ) \ No newline at end of file From dece2ef3b20d63b0e1a14fa2a50a8652a81ce157 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 20:14:35 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20LLM=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=85=EB=A0=A5=EA=B0=92=EC=97=90=20IPC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/search.py | 2 +- app/services/intent.py | 25 +++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/routers/search.py b/app/routers/search.py index e22942b..623c5e2 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -80,7 +80,7 @@ async def search(request: SearchRequest) -> SearchResponse: logger.info(f"[검색 요청] query='{request.query}', filters={request.legal_status}") try: - intent = await interpret_intent(request.query) + intent = await interpret_intent(request.query, domain=request.domain) except ValueError as e: logger.warning(f"의도 해석 실패: {e}") raise HTTPException(status_code=502, detail="검색 의도 해석에 실패했습니다.") diff --git a/app/services/intent.py b/app/services/intent.py index 77c1718..1156efa 100644 --- a/app/services/intent.py +++ b/app/services/intent.py @@ -45,6 +45,14 @@ class IntentResult(BaseModel): 당신은 특허 검색을 도와주는 의도 해석 전문가입니다. 사용자의 자연어 입력을 분석해서 특허 검색 파이프라인이 사용할 구조화된 JSON을 생성하세요. +## 입력 형식 +사용자 메시지에 다음 정보가 포함될 수 있습니다: +- "검색 도메인": 사용자가 미리 선택한 기술 분야 (예: 이차전지, 반도체, AI) +- "사용자 입력": 자연어 검색어 + +도메인이 제공되면 키워드와 IPC 코드 추정 시 해당 도메인에 집중하세요. +도메인이 없으면 사용자 입력만으로 판단하세요. + ## 출력 규칙 다음 JSON 형식만 출력하세요. 다른 설명, 마크다운 코드 블록은 절대 포함하지 마세요. @@ -114,10 +122,23 @@ class IntentResult(BaseModel): GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent" -async def interpret_intent(user_query: str) -> IntentResult: +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}] @@ -125,7 +146,7 @@ async def interpret_intent(user_query: str) -> IntentResult: "contents": [ { "role": "user", - "parts": [{"text": user_query}] + "parts": [{"text": user_text}] } ], "generationConfig": { From 0523f5578d21b206a4f6fe5ea2727b246953be07 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 1 Jun 2026 21:53:34 +0900 Subject: [PATCH 09/16] =?UTF-8?q?docs:=20=EB=8F=99=EC=9D=98=EC=96=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=98=88=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/resources/synonyms_patent.txt | 136 ++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/app/resources/synonyms_patent.txt b/app/resources/synonyms_patent.txt index e69de29..b83ef10 100644 --- a/app/resources/synonyms_patent.txt +++ b/app/resources/synonyms_patent.txt @@ -0,0 +1,136 @@ +# ============================================================ +# 동의어 및 문제-기술 사전 (B60L 전기자동차/충전 도메인) +# 파일 위치 (OpenSearch): infra/opensearch/synonyms_patent.txt +# 파일 위치 (Python): app/resources/synonyms_patent.txt +# ============================================================ +# 형식 규칙: +# - 한 줄에 하나의 동의어 그룹, 쉼표로 구분 +# - '#'으로 시작하는 줄은 주석 +# - 빈 줄은 무시 +# - 그룹 안의 단어들은 서로 동의어/관련어 +# ============================================================ + + +# ===== 1. 도메인 핵심 어휘 (단어 수준 동의어) ===== + +# 전기자동차 +전기자동차, 전기차, electric vehicle, EV, 친환경차, 무공해차 + +# 배터리 / 이차전지 +배터리, 이차전지, secondary cell, 축전지, rechargeable battery, 충전지 + +# 리튬이온전지 +리튬이온전지, lithium ion battery, LIB, 리튬이온이차전지 + +# 충전 +충전, charging, 전력공급 + +# 충전 스테이션 +충전스테이션, 충전기, charging station, 충전인프라, EVSE, EV charger + +# 충전 커넥터 +커넥터, connector, 충전건, 충전플러그, 충전소켓, 충전단자, 인렛, inlet, 충전포트 + +# 케이블 +케이블, cable, 전선, 충전케이블 + + +# ===== 2. 무선 충전 관련 ===== + +# 무선충전 +무선충전, wireless charging, 비접촉식충전, 비접촉충전, 무선전력전송, WPT, wireless power transfer + +# 자기공명 +자기공명, magnetic resonance, 공명결합, resonant coupling + +# 유도결합 +유도결합, inductive coupling, 자기유도, 전자기유도 + +# 송수신 코일 +송신코일, transmitter coil, primary coil, 1차코일, 송신부 +수신코일, receiver coil, secondary coil, 2차코일, 수신부 + +# 정렬 +정렬, alignment, 위치정렬, 코일정렬, 위치맞춤 + + +# ===== 3. 배터리 관리 ===== + +# BMS +BMS, battery management system, 배터리관리시스템 + +# 충전 방식 +정전류정전압, CC-CV, constant current constant voltage +급속충전, fast charging, rapid charging, 고속충전, quick charge +완속충전, slow charging, 일반충전 + +# 충전 상태 +SOC, state of charge, 충전상태, 잔존용량 +SOH, state of health, 건강상태, 열화도 + +# 충전 효율 +충전효율, charging efficiency, 전력효율 + + +# ===== 4. V2X 통신 ===== + +# V2X +V2X, vehicle to everything, 차량통신 +V2G, vehicle to grid, 차량전력망연계 +V2H, vehicle to home +V2V, vehicle to vehicle + +# 근거리 무선통신 +NFC, near field communication, 근거리무선 +RFID, radio frequency identification + + +# ===== 5. 기타 기술 용어 ===== + +# 팬터그래프 +팬터그래프, pantograph, 집전장치, 집전기 + +# 인버터/컨버터 +인버터, inverter, DC-AC변환 +컨버터, converter, 전력변환, DC-DC변환 + +# 모터 +모터, motor, 전동기 + + +# ===== 6. 문제-기술 매핑 ===== + +# 충전 시간 단축 문제 +충전시간, 급속충전, 정전류정전압충전, 충전프로토콜 + +# 충전 효율 향상 문제 +충전효율, 자기공명, 유도결합, 코일정렬 + +# 배터리 수명 / 열화 문제 +배터리수명, 사이클수명, 열관리, 냉각, 열화 + +# 안전성 / 사용 편의성 문제 +안전성, 안전사고예방, 절연, 누설전류차단 +사용편의성, 사용성, 자동연결, 자동도킹 + +# 충전 인터페이스 문제 +충전호환성, 표준화, CCS, CHAdeMO, GBT + +# 무선 충전 거리 / 정렬 문제 +충전거리, 자기공명, 효율향상, 코일설계 +위치오차, 코일정렬, 자동정렬 + +# 차량-인프라 통신 문제 +V2X통신, OCPP, 충전관리시스템 + +# 배터리 안전 문제 +열폭주, 열관리, 셀밸런싱, 과충전방지 + + +# ===== 7. 작용/효과 어휘 ===== + +향상, 개선, 증진, 강화, improvement, enhancement +저감, 감소, 줄임, 억제, reduction +제어, 조절, control +보호, protection +모니터링, 감시, monitoring \ No newline at end of file From fc1234f39c54805e65509a6a9b45942241d3ad3e Mon Sep 17 00:00:00 2001 From: taeik Date: Tue, 2 Jun 2026 01:28:35 +0900 Subject: [PATCH 10/16] =?UTF-8?q?chore:=20GitHub=20PR=20=EB=B0=8F=20Issue?= =?UTF-8?q?=20Templates=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug_report.md | 29 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 24 +++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 20 ++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md 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 From d75b8cc8a08f35192ff2966e37712a6efe584907 Mon Sep 17 00:00:00 2001 From: taeik Date: Tue, 2 Jun 2026 05:04:00 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20OpenSearch=20=ED=83=90=EC=83=89?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 3 + app/routers/search.py | 31 +++- app/services/opensearch_client.py | 225 ++++++++++++++++++++++++++++++ app/services/types.py | 21 +++ 4 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 app/services/types.py diff --git a/app/main.py b/app/main.py index 77ae358..eb0d102 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ 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, @@ -33,6 +34,8 @@ async def lifespan(app: FastAPI): yield # === Shutdown === + logger.info("[Shutdown] 리소스 정리 시작") + await opensearch_service.close() logger.info("[Shutdown] 서버 종료") diff --git a/app/routers/search.py b/app/routers/search.py index 623c5e2..de50abf 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -9,9 +9,11 @@ 2. 동의어 사전으로 키워드 확장 3. HyDE 가상 초록 생성 (Claude) 4. BGE-M3 임베딩 - 5. (다음) OpenSearch + pgvector + KIPRIS 병렬 탐색 - 6. (다음) RRF 병합 - 7. (다음) 추천/요약 생성 + 5-1. OpenSearch 키워드 검색 + 5-2. (다음) pgvector 벡터 검색 + 5-3. (다음) KIPRIS API 호출 + 6. (다음) RRF 병합 + 랭킹 + 7. (다음) 추천 이유/요약 생성 ============================================================ """ @@ -25,6 +27,8 @@ 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"]) @@ -52,7 +56,8 @@ class SearchDebugInfo(BaseModel): """디버깅용 중간 결과 (운영 시 제거 가능)""" expanded_keywords: list[str] hypothetical_abstract: str - embedding_dim: int #1024 + embedding_dim: int # 무조건 1024 + opensearch_results: list[PatentScore] class SearchResponse(BaseModel): @@ -77,7 +82,7 @@ async def search(request: SearchRequest) -> SearchResponse: """ # === Step 1: LLM 의도 해석 === - logger.info(f"[검색 요청] query='{request.query}', filters={request.legal_status}") + logger.info(f"[검색 요청] query='{request.query}', domain={request.domain}, filters={request.legal_status}") try: intent = await interpret_intent(request.query, domain=request.domain) @@ -122,8 +127,21 @@ async def search(request: SearchRequest) -> SearchResponse: 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, + ) + # 다음 단계 - # - 병렬 탐색 (OpenSearch, pgvector, KIPRIS) + # - 병렬 탐색 (pgvector, KIPRIS) # - RRF 병합 및 랭킹 # - 추천/요약 생성 (Claude) @@ -134,5 +152,6 @@ async def search(request: SearchRequest) -> SearchResponse: 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/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/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 From 205169defbb3b06d5ad01e3c75d9c9d49c61008b Mon Sep 17 00:00:00 2001 From: taeik Date: Tue, 2 Jun 2026 05:04:18 +0900 Subject: [PATCH 12/16] =?UTF-8?q?docs:=20=EB=8F=99=EC=9D=98=EC=96=B4=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=88=98=EC=A0=95=20(=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B9=85=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/resources/synonyms_patent.txt | 154 ++++++++++-------------------- 1 file changed, 49 insertions(+), 105 deletions(-) diff --git a/app/resources/synonyms_patent.txt b/app/resources/synonyms_patent.txt index b83ef10..ebd2253 100644 --- a/app/resources/synonyms_patent.txt +++ b/app/resources/synonyms_patent.txt @@ -4,133 +4,77 @@ # 파일 위치 (Python): app/resources/synonyms_patent.txt # ============================================================ # 형식 규칙: -# - 한 줄에 하나의 동의어 그룹, 쉼표로 구분 +# - 한 줄에 하나의 동의어 그룹 +# - 공백 없는 단어만 사용 (다중 단어 영문 토큰 금지) +# - 최소 2개 이상의 단어가 쉼표로 구분 # - '#'으로 시작하는 줄은 주석 # - 빈 줄은 무시 # - 그룹 안의 단어들은 서로 동의어/관련어 # ============================================================ -# ===== 1. 도메인 핵심 어휘 (단어 수준 동의어) ===== +# ===== 도메인 핵심 어휘 ===== -# 전기자동차 -전기자동차, 전기차, electric vehicle, EV, 친환경차, 무공해차 +전기자동차, 전기차, EV, 친환경차 +배터리, 이차전지, 축전지, 충전지 +리튬이온전지, LIB, 리튬이온이차전지 +충전스테이션, 충전인프라, EVSE +커넥터, 충전건, 충전플러그, 충전소켓, 충전단자, 인렛, 충전포트 +케이블, 전선, 충전케이블 -# 배터리 / 이차전지 -배터리, 이차전지, secondary cell, 축전지, rechargeable battery, 충전지 -# 리튬이온전지 -리튬이온전지, lithium ion battery, LIB, 리튬이온이차전지 +# ===== 무선 충전 ===== -# 충전 -충전, charging, 전력공급 +무선충전, 비접촉식충전, 비접촉충전, 무선전력전송, WPT +자기공명, 공명결합 +유도결합, 자기유도, 전자기유도 +송신코일, 1차코일, 송신부 +수신코일, 2차코일, 수신부 +정렬, 위치정렬, 코일정렬, 위치맞춤 -# 충전 스테이션 -충전스테이션, 충전기, charging station, 충전인프라, EVSE, EV charger -# 충전 커넥터 -커넥터, connector, 충전건, 충전플러그, 충전소켓, 충전단자, 인렛, inlet, 충전포트 +# ===== 배터리 관리 ===== -# 케이블 -케이블, cable, 전선, 충전케이블 +BMS, 배터리관리시스템 +정전류정전압, CCCV +급속충전, 고속충전 +완속충전, 일반충전 +SOC, 충전상태, 잔존용량 +SOH, 건강상태, 열화도 +충전효율, 전력효율 -# ===== 2. 무선 충전 관련 ===== +# ===== V2X 통신 ===== -# 무선충전 -무선충전, wireless charging, 비접촉식충전, 비접촉충전, 무선전력전송, WPT, wireless power transfer +V2X, 차량통신 +V2G, 차량전력망연계 +NFC, 근거리무선 +RFID, 무선식별 -# 자기공명 -자기공명, magnetic resonance, 공명결합, resonant coupling -# 유도결합 -유도결합, inductive coupling, 자기유도, 전자기유도 +# ===== 기타 기술 ===== -# 송수신 코일 -송신코일, transmitter coil, primary coil, 1차코일, 송신부 -수신코일, receiver coil, secondary coil, 2차코일, 수신부 +팬터그래프, 집전장치, 집전기 +인버터, 전력변환기 +컨버터, 전력변환 +모터, 전동기 -# 정렬 -정렬, alignment, 위치정렬, 코일정렬, 위치맞춤 +# ===== 문제-기술 매핑 ===== -# ===== 3. 배터리 관리 ===== +충전시간, 충전프로토콜 +배터리수명, 사이클수명, 용량유지율 +열관리, 냉각, 열폭주방지 +안전성, 안전사고예방, 절연 +사용편의성, 사용성, 자동도킹 +충전호환성, 표준화, CCS, CHAdeMO +위치오차, 자동정렬 +열폭주, 셀밸런싱, 과충전방지 -# BMS -BMS, battery management system, 배터리관리시스템 -# 충전 방식 -정전류정전압, CC-CV, constant current constant voltage -급속충전, fast charging, rapid charging, 고속충전, quick charge -완속충전, slow charging, 일반충전 +# ===== 작용/효과 어휘 ===== -# 충전 상태 -SOC, state of charge, 충전상태, 잔존용량 -SOH, state of health, 건강상태, 열화도 - -# 충전 효율 -충전효율, charging efficiency, 전력효율 - - -# ===== 4. V2X 통신 ===== - -# V2X -V2X, vehicle to everything, 차량통신 -V2G, vehicle to grid, 차량전력망연계 -V2H, vehicle to home -V2V, vehicle to vehicle - -# 근거리 무선통신 -NFC, near field communication, 근거리무선 -RFID, radio frequency identification - - -# ===== 5. 기타 기술 용어 ===== - -# 팬터그래프 -팬터그래프, pantograph, 집전장치, 집전기 - -# 인버터/컨버터 -인버터, inverter, DC-AC변환 -컨버터, converter, 전력변환, DC-DC변환 - -# 모터 -모터, motor, 전동기 - - -# ===== 6. 문제-기술 매핑 ===== - -# 충전 시간 단축 문제 -충전시간, 급속충전, 정전류정전압충전, 충전프로토콜 - -# 충전 효율 향상 문제 -충전효율, 자기공명, 유도결합, 코일정렬 - -# 배터리 수명 / 열화 문제 -배터리수명, 사이클수명, 열관리, 냉각, 열화 - -# 안전성 / 사용 편의성 문제 -안전성, 안전사고예방, 절연, 누설전류차단 -사용편의성, 사용성, 자동연결, 자동도킹 - -# 충전 인터페이스 문제 -충전호환성, 표준화, CCS, CHAdeMO, GBT - -# 무선 충전 거리 / 정렬 문제 -충전거리, 자기공명, 효율향상, 코일설계 -위치오차, 코일정렬, 자동정렬 - -# 차량-인프라 통신 문제 -V2X통신, OCPP, 충전관리시스템 - -# 배터리 안전 문제 -열폭주, 열관리, 셀밸런싱, 과충전방지 - - -# ===== 7. 작용/효과 어휘 ===== - -향상, 개선, 증진, 강화, improvement, enhancement -저감, 감소, 줄임, 억제, reduction -제어, 조절, control -보호, protection -모니터링, 감시, monitoring \ No newline at end of file +향상, 개선, 증진, 강화 +저감, 감소, 억제 +보호, 차단 +모니터링, 감시 \ No newline at end of file From e283fda92c636213fa3643357f3d9a28f1c70b43 Mon Sep 17 00:00:00 2001 From: taeik Date: Sun, 28 Jun 2026 16:26:02 +0900 Subject: [PATCH 13/16] =?UTF-8?q?docs:=20Dockerfile=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 65bc90e..45d8de1 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", "main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--proxy-headers"] \ No newline at end of file From ec158e73020e6022bde583a87d544b9ad049c802 Mon Sep 17 00:00:00 2001 From: taeik Date: Sun, 28 Jun 2026 17:12:37 +0900 Subject: [PATCH 14/16] =?UTF-8?q?docs:=20.dockerignore=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .dockerignore 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 From 68d2e684e2253cadcc5ef7cf2e038206074ef70f Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 29 Jun 2026 12:35:06 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20Dockerfile=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 45d8de1..14a82f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ 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", "main:app", \ +CMD ["uvicorn", "app.main:app", \ "--host", "0.0.0.0", \ "--port", "8000", \ "--workers", "1", \ From ae8575f6f5086fe666e1384ce30a3a7f169fa4f8 Mon Sep 17 00:00:00 2001 From: taeik Date: Mon, 29 Jun 2026 12:44:03 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20CICD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 131 +++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 69 +++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml 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