Data Engineering/Elasticsearch

[Elasticsearch] 논문 검색 엔진 구현 프로젝트 (Elasticsearch + Airflow + FastAPI) (3)

seoraroong 2025. 2. 5. 20:44

UI 개발 (feat. Streamlit)

개발을 진행하면서 CMD로 요청을 보내고 응답을 받는 과정이 번거롭고, 가독성도 좋지 않아서 간단한 UI를 개발했다.

스택은 streamlit을 활용했다.

(react를 써보고 싶었으나, 시간적 이슈로 인해 간단한 방법을 선택했다.. react는 추후 포스팅할 프로젝트에서 다룰 예정이다.. ㅎ)

 

streamlit 또한 컨테이너로 만들어서 다른 서비스와 함께 도커 컴포즈로 개발할 것이다.

프로젝트 구조는 다음과 같다.

 

Dockerfile (Streamlit)

FROM python:3.9

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]

 

requirements.txt (Streamlit)

streamlit
requests

streamlit_app.py

import streamlit as st
import requests

# FastAPI 백엔드 URL (컨테이너 네트워크에서 FastAPI와 통신하기 위함)
BACKEND_URL = "http://fastapi:8000/search"

st.title("📚 BioResearch-Paper Search Engine")
st.write("Elasticsearch 기반 bio 논문 검색")

# 검색 입력창
query = st.text_input("검색어를 입력하세요", "")

# 저자 검색 옵션
author = st.text_input("저자 이름을 입력하세요", "")

# 검색 버튼
if st.button("검색"):
    params = {"query": query}
    if author:
        params["author"] = author
    
    response = requests.get(BACKEND_URL, params=params)

    if response.status_code == 200:
        results = response.json().get("results", [])
        if results:
            st.write(f"🔍 {len(results)}개의 논문이 검색되었습니다.")
            for paper in results:
                source = paper["_source"]
                st.subheader(source["title"])
                st.write(f"**저자:** {', '.join([author['name'] for author in source['authors']])}")
                st.write(f"📅 **출판일:** {source['publication_date']}")
                st.write(f"📖 **요약:** {source['abstract'][:500]}...")
                st.write(f"[🔗 논문 링크]({source['url']})")
                st.write("---")
        else:
            st.warning("검색 결과가 없습니다")
    else:
        st.error("검색 요청에 실패했습니다")

수정된 docker-compose.yml (Streamlit 서비스 추가)

version: '3.7'
services:

  # PostgreSQL (Airflow metadata DB)
  postgres:
    image: postgres:13
    container_name: postgres_db
    restart: always
    environment:
      POSTGRES_USER: airflow
      POSTGRES_PASSWORD: airflow
      POSTGRES_DB: airflow
    ports:
      - "5432:5432"
    networks:
      - elastic_network
    volumes:
      - postgres_data:/var/lib/postgresql/data

  # Elasticsearch
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ports:
      - "9200:9200"
      - "9300:9300"
    networks:
      - elastic_network

  # Kibana
  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    container_name: kibana
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch
    networks:
      - elastic_network
  
  # FastAPI
  fastapi:
    build: ../fastapi
    container_name: fastapi
    ports:
      - "8000:8000"
    depends_on:
      - elasticsearch
    networks:
      - elastic_network
  
  # Airflow
  airflow:
    build: ../airflow
    container_name: airflow
    restart: always
    ports:
      - "8080:8080"
    depends_on:
      - elasticsearch
      - postgres
    environment:
      - AIRFLOW_HOME=/opt/airflow
      - AIRFLOW__CORE__EXECUTOR=LocalExecutor
      - AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres:5432/airflow
      - AIRFLOW__CORE__LOAD_EXAMPLES=False    
    volumes:
      - airflow_db:/opt/airflow
      - ../airflow/dags:/opt/airflow/dags # DAG 폴더 마운트
    networks:
      - elastic_network
  
  # Streamlit
  streamlit:
    build: ../streamlit
    container_name: streamlit
    ports:
      - "8501:8501"
    depends_on:
      - fastapi
      - elasticsearch
    networks:
      - elastic_network

networks:
  elastic_network:
    driver: bridge

volumes:
  postgres_data:
  airflow_db:

 

위 코드를 모두 작성 후 컨테이너를 종료한 뒤 다시 시작해준다.

docker-compose down
docker-compose up --build -d

 

Streamlit 접속 및 검색 테스트

http://localhost:8501에 접속하자.

 

- 검색 테스트 1: 기본 검색에 "COVID-19" 입력 후 검색 수행

 

논문 링크를 클릭하면 해당 논문 페이지로 이동한다!

 

- 검색 테스트 2: 저자 검색

저자 풀네임을 입력해도 검색 결과가 없다는 메시지가 나와서 CMD로 요청을 보내봤다.

 

curl 요청 결과를 분석한 결과, "query" 필드가 필수로 설정되어 있어서 author 검색만 수행할 때 에러가 발생한다.

FastAPI의 Query 매개변수에서 query: str을 필수 값으로 받고 있어서 발생한 오류로 추정했다.

 

FastAPI app.py 수정

query를 선택적 (Optional) 매개변수로 변경해주자.

from fastapi import FastAPI, Query
from elasticsearch import Elasticsearch

app = FastAPI()
es = Elasticsearch("http://elasticsearch:9200")

@app.get("/search")
async def search_papers(
    query: str = Query(None, description="Search by keyword"),
    author: str = Query(None, description="Search by author's name"),
    sort_by: str = Query("publication_date", description="Sort field"),
    order: str = Query("desc", description="Sort order ('asc' or 'desc')")
):
    """Elasticsearch에서 논문 검색 수행"""

    must_clauses = []

    # 키워드 검색 추가 (query가 존재하는 경우)
    if query:
        must_clauses.append(
            {"multi_match": {
                "query": query,
                "fields": ["title", "abstract", "category"]
            }}
        )

    # 저자 검색 추가 (author가 존재하는 경우)
    if author:
        must_clauses.append(
            {
                "nested": {
                    "path": "authors",
                    "query": {
                        "match": {
                            "authors.name": author
                        }
                    }
                }
            }
        )

    # 검색어 또는 저자 중 하나는 반드시 입력해야 함
    if not must_clauses:
        return {"error": "검색어 또는 저자를 입력해야 합니다."}

    query_body = {
        "query": {
            "bool": {
                "must": must_clauses
            }
        },
        "sort": [
            {sort_by: {"order": order}}
        ]
    }

    response = es.search(index="research_papers", body=query_body)

    return {"results": response["hits"]["hits"]}


@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}

 

다시 저자 검색 테스트를 수행했다.

 

저자 검색도 성공적으로 작동한다!