IT/Elasticsearch

[Elasticsearch] 완전 무료 한국어 검색엔진 도입기 (Ollama + nomic-embed-text, 768차원 하이브리드 검색)

snapcoder 2026. 7. 5. 15:02
728x90
반응형
SMALL

왜 Elasticsearch였는가

기존 검색은 PostgreSQL의 ILIKE '%keyword%' 방식으로 동작했습니다. "백엔드 개발자"를 검색하면 해당 문자열이 그대로 포함된 데이터만 조회됩니다. "백엔드"와 "개발자"가 따로 포함된 데이터, 또는 "개발"이라는 어근을 공유하는 표현은 놓치게 됩니다.

한국어 형태소 분석과 BM25 랭킹을 통해 관련도 기반 결과를 제공하고, 나아가 Ollama의 nomic-embed-text 모델로 임베딩을 생성해 키워드가 달라도 의미가 가까운 데이터를 찾는 시맨틱 검색까지 붙이는 것이 목표였습니다.

 

ES관련 자세한 내용은 아래 공식링크를 참고하시면 됩니다.

https://www.elastic.co/guide/kr/elasticsearch/reference/5.4/getting-started.html

 

시작하기 | Elasticsearch 설명서 [5.4] | Elastic

Elasticsearch는 확장성이 뛰어난 오픈소스 풀텍스트 검색 및 분석 엔진입니다. 방대한 양의 데이터를 신속하게, 거의 실시간으로 저장, 검색, 분석할 수 있도록 지원합니다. 일반적으로 복잡한 검

www.elastic.co

 

 

 

 

도입 결과화면

"차량"으로 검색시 kNN+BM25 기반으로, 자율주행이나 모빌리티 공고들이 성공적으로 조회되는 모습입니다.

 

"데이터베이스"도 마찬가지로 정상 조회됩니다.


MCP도 정상적으로 응답값을 내려줍니다.

 

 

 

서버 구성

ES 전용 인스턴스를 별도로 분리했습니다. Oracle Cloud ARM64 인스턴스이며, Elasticsearch와 Ollama가 같은 서버에서 동작합니다.

항목
인프라 Oracle Cloud (VM.Standard.A1.Flex)
CPU 2코어, ARM64 (aarch64, Neoverse-N1)
RAM 10GB 총 / ES 프로세스 약 4.5GB 점유 / available 약 5.2GB
Disk 30GB 총 / 12GB 사용 / 19GB 여유
OS Oracle Linux 9.7
주요 서비스 Elasticsearch 8.18.2 + analysis-nori, Ollama (nomic-embed-text)
방화벽 9200 포트 앱 서버 IP만 허용 (외부 전체 오픈 아님)

 

Oracle Cloud는 ARM 기반 인스턴스를 무료로 제공합니다. 여기에 Ollama까지 구동해 임베딩 API 비용을 완전 무료로 해결했습니다.



왜 Ollama + nomic-embed-text인가

OpenAI text-embedding-ada-002나 Cohere Embed 같은 유료 임베딩 API를 사용하면 색인 건수가 늘어날수록 비용이 발생합니다. Ollama는 모델을 로컬 서버에서 구동하므로 API 호출 비용이 0원입니다.

 

nomic-embed-textNomic AI(https://www.nomic.ai/)에서 오픈소스로 공개한 임베딩 모델입니다. 768차원 벡터를 생성하며 한국어도 지원합니다.

# Ollama 설치 후 모델 Pull (약 274MB)
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

# 임베딩 테스트
curl http://localhost:11434/api/embeddings \
  -d '{"model":"nomic-embed-text","prompt":"백엔드 개발자 5년 경력"}'
# → "embedding": [0.123, -0.456, ...]  768개 float 반환

 





인덱스 설계



Nori 형태소 분석기

ES에 analysis-nori(https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori.html) 플러그인을 설치하고 커스텀 분석기를 정의했습니다. "백엔드 개발자 채용"을 입력하면 백엔드, 개발자, 채용 단위로 토크나이징되어 단어 순서가 달라져도 매칭됩니다.

// Nori 커스텀 분석기 — 한국어 토크나이저 + 불용어 필터
.analysis(a -> a
    .analyzer("nori_analyzer", an -> an
        .custom(cu -> cu
            .tokenizer("nori_tokenizer")
            .filter(List.of("lowercase", "nori_part_of_speech")) // 소문자화 + 품사 필터
        )
    )
)

 



인덱스 매핑

검색 텍스트 필드는 nori_analyzer를 적용하고, 임베딩 벡터는 dense_vector 타입으로 선언해 kNN 검색을 활성화했습니다. (의미 검색 제공 위함!)

.mappings(m -> m
    .properties("company_name", p -> p
        .text(t -> t.analyzer("nori_analyzer")
            .fields("keyword", f -> f.keyword(k -> k))) // Nori 검색 + keyword 서브필드
    )
    .properties("title",          p -> p.text(t -> t.analyzer("nori_analyzer")))
    .properties("tasks",          p -> p.text(t -> t.analyzer("nori_analyzer")))
    .properties("qualifications", p -> p.text(t -> t.analyzer("nori_analyzer")))
    .properties("end_at",         p -> p.date(d -> d))
    .properties("del_yn",         p -> p.keyword(k -> k))
    .properties("embedding", p -> p.denseVector(dv -> dv
        .dims(768)                              // nomic-embed-text 차원
        .index(true)                            // kNN 인덱스 활성화
        .similarity(DenseVectorSimilarity.Cosine) // 코사인 유사도 (8.18.x: String 아닌 enum)
    ))
)

 





검색 전략 — kNN + BM25 하이브리드



왜 하이브리드인가

키워드 검색(BM25)은 정확한 단어 매칭에 강하지만 동의어나 문맥을 놓칩니다. 시맨틱 검색(kNN)은 의미 유사도를 잡지만 정확한 기업명·직무명에는 약합니다. 두 방식을 병행하면 서로의 약점을 보완합니다.

Ollama가 응답하지 않으면 임베딩 생성이 실패하고 BM25 단독으로 자동 폴백됩니다. 임베딩 서버 장애가 검색 전체 장애로 이어지지 않도록 설계했습니다.



BM25 필드 부스팅

// 멀티 필드 검색 — 기업명·제목 최우선, 업무·자격 2순위
.multiMatch(mm -> mm
    .query(query)
    .fields(List.of(
        "company_name^3", "title^3",           // 1순위 (가중치 3)
        "tasks^2",        "qualifications^2",  // 2순위 (가중치 2)
        "preferred_qualifications", "location" // 3순위 (가중치 1)
    ))
    .fuzziness("AUTO") // 단어 길이에 따라 오탈자 1~2글자 자동 허용
)

 



kNN 벡터 검색

// queryEmbedding이 null이면 이 블록 스킵 → BM25 단독 실행
if (queryEmbedding != null) {
    searchBuilder.knn(k -> k
        .field("embedding")
        .queryVector(queryEmbedding) // 질의 벡터 (768차원)
        .numCandidates(100)          // 후보 100건에서 k건 선별 (HNSW 근사 탐색, 8.18.x: int)
        .k(limit)
        .filter(activeFilters)       // BM25와 동일한 필터 적용 (만료·삭제 제외)
    );
}

 

BM25와 kNN을 함께 지정하면 ES가 내부적으로 두 점수를 결합해 최종 순위를 결정합니다. 실제 사용된 방식은 로그에 kNN+BM25 하이브리드 또는 BM25 단독으로 출력됩니다.





색인 전략 — 풀색인 vs 증분색인

데이터는 두 가지 경로로 ES에 반영됩니다.

구분 트리거 엔드포인트 목적
풀색인 (데이터) 앱 기동 시 / 매일 03:00 (KST) cron POST /api/v1/es/sync/bulk 전체 정합성 보정 (매핑 유지)
풀색인 (매핑 변경) 매핑 변경 시 수동 실행 POST /api/v1/es/fullindexing 인덱스 삭제 → 재생성 → 임베딩 포함 전체 색인
증분색인 크롤러 배치 commit 직후 POST /api/v1/es/sync/{id} 신규·수정 건 실시간 반영
삭제색인 크롤러 논리삭제 commit 직후 DELETE /api/v1/es/sync/{id} 만료 데이터 즉시 제거

 

크롤러(Python)는 DB에 commit한 직후 Spring Boot REST API를 호출해 ES 반영을 요청합니다.

색인 실패는 WARNING 로그만 남기고 계속 진행하므로, 색인 오류가 크롤링 전체를 멈추지 않습니다.

누락이 생겨도 다음 풀색인 시 자동 보정됩니다.

# 크롤러 — 배치 commit 직후 증분 색인 요청 (실패해도 크롤링 계속 진행)
def _sync_es_ids(self, ids):
    spring_url = os.getenv('SPRING_BOOT_URL', 'http://localhost:8080')
    for job_id in ids:
        try:
            req = urllib.request.Request(
                f"{spring_url}/api/v1/es/sync/{job_id}", method='POST'
            )
            urllib.request.urlopen(req, timeout=5)
        except Exception as e:
            logger.warning(f"[ES] 증분 색인 실패 id={job_id} (무시): {e}")

 



풀색인 시 인덱스 재생성 조건

인덱스 매핑이 변경됐을 때(예: embedding 필드 추가)는 기존 인덱스를 그대로 쓸 수 없습니다. 앱 기동 시 embedding 필드 존재 여부를 확인해, 없으면 인덱스를 삭제하고 새 매핑으로 재생성합니다.

public void createIndexIfNotExists() {
    boolean exists = esClient.indices().exists(e -> e.index(INDEX)).value();
    if (exists) {
        if (!hasEmbeddingField()) {
            deleteIndex(); // 구버전 인덱스 삭제 후 재생성
        } else {
            return;        // 이미 최신 매핑 — 스킵
        }
    }
    esClient.indices().create(buildIndexRequest());
}

 





사용자 검색 흐름

검색 요청이 들어왔을 때 내부적으로 거치는 경로입니다.

검색바 (/api/jobs?query=...)

메인 화면 검색창에서 입력 시 ES를 먼저 시도하고, 실패 또는 결과 없을 때 SQL로 폴백합니다.

검색바 입력 (예: "은행")
       │
       ▼
 GET /api/jobs?query=은행
       │
   ┌───┴───────────────────────────┐
   │ ES 정상                       │ ES 장애 / 결과 없음
   ▼                               ▼
 ElasticsearchService          SQL ILIKE Fallback
 .search() 상위 500건 ID        (c.name ILIKE '%은행%'
 추출 (kNN+BM25 하이브리드)      OR jp.name ILIKE '%은행%')
       │
 SQL IN (id1, id2, ...)
 + 소스/정규직 필터 적용
       │
 ES 관련도 순 재정렬 후
 Java 페이지네이션

ES 사용 시 정렬은 ES 관련도 점수 순으로 고정됩니다 (화면의 마감순/등록순 설정 무관).

 

 

ES → RDB Fallback

ES가 응답하지 않거나 결과가 없을 때는 PostgreSQL의 ILIKE 쿼리로 자동 전환됩니다. 사용자 입장에서는 검색 품질 차이는 있어도 오류 없이 결과를 받습니다. 실제 사용된 경로는 로그의 method 값으로 구분됩니다.

method 값 의미
ES_HYBRID kNN+BM25 하이브리드 (Ollama 정상 시)
ES_BM25 BM25 단독 (Ollama 실패 → 자동 폴백)
RDB_ILIKE ES 장애 또는 결과 없음 → PostgreSQL fallback
ES_RECENT 키워드 없이 최신순 조회 (ES)
RDB_RECENT 최신순 조회 ES 실패 → PostgreSQL fallback

 




도입 전후 비교

항목 도입 전 (ILIKE) 도입 후 (ES)
검색 방식 단순 문자열 포함 여부 BM25 랭킹 + kNN 시맨틱
한국어 처리 없음 Nori 형태소 분석
오탈자 허용 없음 Fuzziness AUTO (1~2글자)
임베딩 비용 해당 없음 0원 (Ollama 자체 서버)
장애 내성 단일 경로 ES 장애 시 RDB 자동 전환

 

Oracle Cloud 무료 인스턴스 + Ollama 오픈소스 조합으로 임베딩 API 비용 없이 시맨틱 검색을 붙일 수 있었습니다. analysis-nori 플러그인 설치와 nomic-embed-text 모델 다운로드 두 단계만으로 준비가 끝납니다.





트러블슈팅 — Oracle Cloud 네트워크 이중 방화벽

ES 서버와 앱 서버를 분리하면서 서버 간 통신이 막히는 문제가 있었습니다. OS 방화벽(firewalld)에서 포트를 열었는데도 연결이 되지 않았습니다.

Oracle Cloud에는 방화벽이 두 단계 존재합니다.

레이어 위치 설정 방법
VCN Security List 클라우드 인프라 레벨 (Oracle Cloud Console) Networking → VCN → Security Lists → Ingress Rule 추가
OS firewalld 인스턴스 OS 레벨 sudo firewall-cmd --add-rich-rule=...

같은 VCN 서브넷 내부 통신도 VCN Security List가 적용됩니다. 두 서버가 같은 서브넷에 있어도 Security List에 인바운드 규칙이 없으면 차단됩니다. OS 방화벽만 열면 된다고 생각했다가 놓치기 쉬운 부분입니다.

또한 같은 VCN 내 서버 간 통신은 공인 IP가 아닌 내부 IP로 접근해야 합니다. 공인 IP는 VCN 외부에서 NAT을 통해 접근하는 경로이므로, 같은 네트워크 내부에서는 라우팅이 되지 않습니다.

이 문제는 Elasticsearch(9200)뿐 아니라 Ollama(11434)에도 동일하게 적용됩니다. OLLAMA_URL 환경변수에 외부 IP를 설정해보니, 앱 서버에서 임베딩 요청이 타임아웃났었고 이를 해결했었습니다.

VCN Security List  →  10.0.0.xxx/32, TCP, 9200   ✅
VCN Security List  →  10.0.0.xxx/32, TCP, 11434  ✅
OS firewalld       →  source 10.0.0.xxx port 9200   ✅
OS firewalld       →  source 10.0.0.xxx port 11434  ✅
ES_HOST            →  내부 IP (GitHub Secret)        ✅
OLLAMA_URL         →  http://내부IP:11434 (GitHub Secret)  ✅

 

 

728x90
반응형
LIST