karboosx 블로그 포스트 번역
2025-11-09
왜 직접 만들까?
보세요, 당신이 무슨 생각을 하는지 알아요. "Elasticsearch를 쓰면 안 되나?" 또는 "Algolia는 어때?" 이런 생각 말이에요. 이것들은 유효한 선택지지만, 복잡성이 따릅니다. API를 배워야 하고, 인프라를 관리해야 하고, 그들의 특이한 점들을 다루어야 합니다.
때로는 다음과 같은 것이 필요할 뿐입니다:
이것이 내가 만든 것입니다. 기존 데이터베이스를 사용하고, 현재 아키텍처를 존중하며, 작동 방식에 대한 완전한 제어를 제공하는 검색 엔진입니다.
핵심 아이디어
개념은 간단합니다: 모든 것을 토큰화하고, 저장한 다음, 검색할 때 토큰을 매칭합니다.
작동 방식은 다음과 같습니다:
- 인덱싱: 콘텐츠를 추가하거나 업데이트할 때, 텍스트를 토큰(단어, 접두사, n-gram)으로 분할하고 가중치와 함께 저장합니다
- 검색: 누군가 검색할 때, 쿼리를 같은 방식으로 토큰화하고, 일치하는 토큰을 찾아 결과를 점수 매깁니다
- 점수 매기기: 저장된 가중치를 사용하여 관련성 점수를 계산합니다
마법은 토큰화와 가중치 부여에 있습니다. 제가 보여드리겠습니다.
구성 요소 1: 데이터베이스 스키마
두 개의 간단한 테이블이 필요합니다: index_tokens과 index_entries.
index_tokens
이 테이블은 모든 고유 토큰을 토크나이저 가중치와 함께 저장합니다. 각 토큰 이름은 여러 레코드를 가질 수 있으며, 각각 다른 가중치를 가집니다 - 토크나이저당 하나씩.
왜 가중치별로 별도의 토큰을 저장할까요? 다른 토크나이저가 같은 토큰을 다른 가중치로 생성합니다. 예를 들어, WordTokenizer의 "parser"는 가중치 20을 가지지만, PrefixTokenizer의 "parser"는 가중치 5를 가집니다. 일치를 올바르게 점수 매기려면 별도의 레코드가 필요합니다.
고유 제약은 (name, weight)에 있으므로, 같은 토큰 이름이 다른 가중치로 여러 번 존재할 수 있습니다.
index_entries
이 테이블은 토큰을 필드별 가중치가 있는 문서와 연결합니다.
여기서 weight는 최종 계산된 가중치입니다: field_weight × tokenizer_weight × ceil(sqrt(token_length)). 이것은 점수 매기기에 필요한 모든 것을 인코딩합니다. 점수 매기기에 대해서는 나중에 포스트에서 이야기하겠습니다.
인덱스를 추가합니다:
(document_type, document_id) - 빠른 문서 조회
(document_type, field_id) - 필드별 쿼리
왜 이 구조일까요? 간단하고 효율적이며, 데이터베이스가 잘하는 것을 활용합니다.
구성 요소 2: 토큰화
토큰화란 무엇일까요? 텍스트를 검색 가능한 조각으로 나누는 것입니다. "parser"라는 단어는 사용하는 토크나이저에 따라 ["parser"], ["par", "pars", "parse", "parser"], 또는 ["par", "ars", "rse", "ser"]과 같은 토큰이 됩니다.
왜 여러 토크나이저를 사용할까요? 다른 매칭 필요에 따른 다른 전략입니다. 정확한 매칭을 위한 토크나이저, 부분 매칭을 위한 토크나이저, 오타를 위한 토크나이저.
모든 토크나이저는 간단한 인터페이스를 구현합니다:
간단한 계약, 확장하기 쉽습니다.
Word Tokenizer
이것은 간단합니다 - 텍스트를 개별 단어로 분할합니다. "parser"는 단순히 ["parser"]가 됩니다. 간단하지만 정확한 매칭에 강력합니다.
먼저 텍스트를 정규화합니다. 모든 것을 소문자로, 특수 문자 제거, 공백 정규화:
다음으로 단어로 분할하고 짧은 것들을 필터링합니다:
왜 짧은 단어를 필터링할까요? 한 글자 단어는 보통 검색에 유용하기에는 너무 흔합니다. "a", "I", "x"는 검색에 도움이 되지 않습니다.
마지막으로 고유한 단어를 Token 객체로 반환합니다:
가중치: 20 (정확한 매칭에 높은 우선순위)
Prefix Tokenizer
이것은 단어 접두사를 생성합니다. "parser"는 ["par", "pars", "parse", "parser"]가 됩니다 (최소 길이 4). 이것은 부분 매칭과 자동완성 같은 동작을 도와줍니다.
먼저 단어를 추출합니다 (WordTokenizer와 같은 정규화):
그 다음, 각 단어에 대해 최소 길이에서 전체 단어까지의 접두사를 생성합니다:
왜 연관 배열을 사용할까요? 고유성을 보장합니다. "parser"가 텍스트에 두 번 나타나면, "parser" 토큰은 하나만 원합니다.
마지막으로 키를 Token 객체로 변환합니다:
가중치: 5 (중간 우선순위)
왜 최소 길이일까요? 너무 많은 작은 토큰을 피하기 위해. 4자 미만의 접두사는 보통 유용하기에는 너무 흔합니다.
N-Grams Tokenizer
이것은 고정 길이의 문자 시퀀스를 만듭니다 (저는 3을 사용합니다). "parser"는 ["par", "ars", "rse", "ser"]이 됩니다. 이것은 오타와 부분 단어 매칭을 잡습니다.
먼저 단어를 추출합니다:
그 다음, 각 단어에 대해 고정 길이의 윈도우를 슬라이드합니다:
슬라이딩 윈도우: 길이 3인 "parser"의 경우:
왜 이것이 작동할까요? 누군가 "parsr"을 입력해도 (오타), 우리는 여전히 "par"과 "ars" 토큰을 얻으며, 이는 올바르게 철자된 "parser"와 일치합니다.
마지막으로 Token 객체로 변환합니다:
가중치: 1 (낮은 우선순위, 하지만 엣지 케이스를 잡음)
왜 3일까요? 커버리지와 노이즈 사이의 균형. 너무 짧으면 너무 많은 매칭을 얻고, 너무 길면 오타를 놓칩니다.
정규화
모든 토크나이저는 같은 정규화를 수행합니다:
이것은 입력 형식에 관계없이 일관된 매칭을 보장합니다.
구성 요소 3: 가중치 시스템
세 가지 수준의 가중치가 함께 작동합니다:
- 토크나이저 가중치: 단어 vs 접두사 vs n-gram (index_tokens에 저장)
- 문서 가중치: index_entries에 저장 (계산:
field_weight × tokenizer_weight × ceil(sqrt(token_length)))
최종 가중치 계산
인덱싱할 때, 최종 가중치를 다음과 같이 계산합니다:
예를 들어:
- 최종 가중치:
10 × 20 × ceil(sqrt(6)) = 10 × 20 × 3 = 600
왜 ceil(sqrt())를 사용할까요? 더 긴 토큰이 더 구체적이지만, 매우 긴 토큰으로 가중치가 폭발하는 것을 원하지 않습니다. "parser"는 "par"보다 더 구체적이지만, 100자 토큰이 100배의 가중치를 가져서는 안 됩니다. 제곱근 함수는 우리에게 수확 체감을 제공합니다 - 더 긴 토큰은 여전히 더 높은 점수를 받지만, 선형적이지 않습니다. 우리는 ceil()을 사용하여 가장 가까운 정수로 올림하고, 가중치를 정수로 유지합니다.
가중치 조정
사용 사례에 맞게 가중치를 조정할 수 있습니다:
- 제목이 가장 중요하면 필드 가중치를 증가시킵니다
- 정확한 매칭을 우선시하려면 토크나이저 가중치를 증가시킵니다
- 더 긴 토큰이 더 중요하거나 덜 중요하도록 하려면 토큰 길이 함수(ceil(sqrt), log, 또는 선형)를 조정합니다
가중치가 어떻게 계산되는지 정확히 볼 수 있고 필요에 따라 조정할 수 있습니다.
구성 요소 4: 인덱싱 서비스
인덱싱 서비스는 문서를 가져와 모든 토큰을 데이터베이스에 저장합니다.
인터페이스
인덱싱할 수 있는 문서는 IndexableDocumentInterface를 구현합니다:
문서를 검색 가능하게 만들려면 이 세 가지 메서드를 구현합니다:
구현할 세 가지 메서드:
getDocumentType(): 문서 타입 enum 반환
getDocumentId(): 문서 ID 반환
getIndexableFields(): fluent API를 사용하여 가중치가 있는 필드 구축
문서를 인덱싱할 수 있습니다:
- 명령어를 통해:
app:index-document, app:reindex-documents
작동 방식
인덱싱 프로세스, 단계별로.
먼저 문서 정보를 가져옵니다:
문서는 IndexableFields 빌더를 통해 필드와 가중치를 제공합니다.
다음으로 이 문서의 기존 인덱스를 제거합니다. 이것은 업데이트를 처리합니다 - 문서가 변경되면 재인덱싱해야 합니다:
왜 먼저 제거할까요? 새 토큰만 추가하면 중복이 생깁니다. 처음부터 시작하는 것이 낫습니다.
이제 각 필드를 처리합니다. 각 필드에 대해 모든 토크나이저를 실행합니다:
각 토크나이저에 대해 토큰을 얻습니다. 그 다음, 각 토큰에 대해 데이터베이스에서 찾거나 생성하고 최종 가중치를 계산합니다:
왜 배치 삽입일까요? 성능. 한 번에 한 행씩 삽입하는 대신, 모든 행을 수집하고 한 번의 쿼리로 삽입합니다.
마지막으로 모든 것을 배치 삽입합니다:
findOrCreateToken 메서드는 간단합니다:
왜 찾거나 생성할까요? 토큰은 문서 간에 공유됩니다. "parser"가 이미 가중치 20으로 존재하면 재사용합니다. 중복을 만들 필요가 없습니다.
핵심 포인트:
- 먼저 이전 인덱스를 제거합니다 (업데이트 처리)
- 성능을 위해 배치 삽입합니다 (많은 쿼리 대신 하나)
구성 요소 5: 검색 서비스
검색 서비스는 쿼리 문자열을 가져와 관련 문서를 찾습니다. 인덱싱 중에 문서를 토큰화한 것과 같은 방식으로 쿼리를 토큰화한 다음, 이 토큰들을 데이터베이스의 인덱싱된 토큰과 매칭합니다. 결과는 관련성으로 점수 매겨지고 문서 ID와 점수로 반환됩니다.
작동 방식
검색 프로세스, 단계별로.
먼저 모든 토크나이저를 사용하여 쿼리를 토큰화합니다:
쿼리가 토큰을 생성하지 않으면 (예: 특수 문자만), 빈 결과를 반환합니다.
왜 같은 토크나이저를 사용하여 쿼리를 토큰화할까요?
다른 토크나이저는 다른 토큰 값을 생성합니다. 하나의 세트로 인덱싱하고 다른 것으로 검색하면 매칭을 놓칩니다.
예:
- PrefixTokenizer로 인덱싱하면 토큰이 생성됩니다: "par", "pars", "parse", "parser"
- WordTokenizer만으로 검색하면 토큰이 생성됩니다: "parser"
- "parser"는 찾을 수 있지만, "par" 또는 "pars" 토큰만 있는 문서는 찾을 수 없습니다
해결책: 인덱싱과 검색 모두에 같은 토크나이저를 사용합니다. 같은 토큰화 전략 = 같은 토큰 값 = 완전한 매칭.
이것이 SearchService와 SearchIndexingService 모두 같은 토크나이저 세트를 받는 이유입니다.
다음으로 고유한 토큰 값을 추출합니다. 여러 토크나이저가 같은 토큰 값을 생성할 수 있으므로 중복 제거합니다:
왜 값을 추출할까요? 토큰 이름으로 검색합니다. 검색하려면 고유한 토큰 이름이 필요합니다.
그 다음, 토큰을 길이순으로 정렬합니다 (가장 긴 것부터). 이것은 구체적인 매칭을 우선시합니다:
왜 정렬할까요? 더 긴 토큰이 더 구체적입니다. "parser"는 "par"보다 더 구체적이므로 "parser"를 먼저 검색하고 싶습니다.
또한 거대한 쿼리로 인한 DoS 공격을 방지하기 위해 토큰 수를 제한합니다:
왜 제한할까요? 악의적인 사용자가 수천 개의 토큰을 생성하는 쿼리를 보낼 수 있으며, 이는 성능 문제를 일으킵니다. 가장 긴 300개 토큰을 유지합니다 (이미 정렬됨).
이제 최적화된 SQL 쿼리를 실행합니다. executeSearch() 메서드는 SQL 쿼리를 구축하고 실행합니다:
executeSearch() 내에서 매개변수 자리 표시자가 있는 SQL 쿼리를 구축하고, 실행하고, 낮은 점수 결과를 필터링하고, SearchResult 객체로 변환합니다:
SQL 쿼리는 무거운 작업을 수행합니다: 일치하는 문서를 찾고, 점수를 계산하고, 관련성으로 정렬합니다. 성능과 완전한 제어를 위해 원시 SQL을 사용합니다 - 필요한 대로 정확히 쿼리를 최적화할 수 있습니다.
쿼리는 토큰과 문서를 연결하는 JOIN, 정규화를 위한 서브쿼리, 점수 매기기를 위한 집계, 토큰 이름, 문서 타입, 가중치에 대한 인덱스를 사용합니다. 보안을 위해 매개변수 바인딩을 사용합니다 (SQL 주입 방지).
다음 섹션에서 전체 쿼리를 볼 것입니다.
주요 search() 메서드는 결과를 반환합니다:
점수 매기기 알고리즘
점수 매기기 알고리즘은 여러 요소의 균형을 맞춥니다. 단계별로 분석해봅시다.
기본 점수는 모든 일치하는 토큰 가중치의 합입니다:
sd.weight: index_entries에서 (field_weight × tokenizer_weight × ceil(sqrt(token_length)))
왜 st.weight를 곱하지 않을까요? 토크나이저 가중치는 이미 인덱싱 중에 sd.weight에 포함되어 있습니다. index_tokens의 st.weight는 필터링을 위해서만 전체 SQL 쿼리의 WHERE 절에서 사용됩니다 (최소 하나의 토큰이 weight >= minTokenWeight를 가지도록 보장).
이것은 우리에게 원시 점수를 제공합니다. 하지만 더 필요합니다.
토큰 다양성 부스트를 추가합니다. 더 많은 고유 토큰과 일치하는 문서가 더 높은 점수를 받습니다:
왜? 5개의 다른 토큰과 일치하는 문서는 같은 토큰 5번과 일치하는 것보다 더 관련성이 있습니다. LOG 함수는 이 부스트를 로그 함수로 만듭니다 - 10개 토큰과 일치하는 것이 10배 부스트를 주지는 않습니다.
또한 평균 가중치 품질 부스트를 추가합니다. 더 높은 품질의 매칭을 가진 문서가 더 높은 점수를 받습니다:
왜? 높은 가중치 매칭(예: 제목 매칭)을 가진 문서는 낮은 가중치 매칭(예: 콘텐츠 매칭)을 가진 것보다 더 관련성이 있습니다. 다시 LOG는 로그 함수입니다.
문서 길이 페널티를 적용합니다. 긴 문서가 지배하는 것을 방지합니다:
왜? 1000단어 문서가 자동으로 100단어 문서를 이기지는 않습니다. LOG 함수는 이 페널티를 로그 함수로 만듭니다 - 10배 더 긴 문서가 10배 페널티를 받지는 않습니다.
마지막으로 최대 점수로 나누어 정규화합니다:
이것은 0-1 범위를 제공하여 다양한 쿼리 간에 점수를 비교할 수 있게 합니다.
전체 공식은 다음과 같습니다:
왜 st2.weight >= ?를 가진 서브쿼리일까요? 이것은 의미 있는 토크나이저 가중치를 가진 최소 하나의 일치하는 토큰을 가진 문서만 포함하도록 보장합니다. 이 필터 없이, 낮은 우선순위 토큰(예: 가중치 1인 n-gram)만 일치하는 문서도 포함되며, 높은 우선순위 토큰(예: 가중치 20인 단어)과 일치하지 않습니다. 이 서브쿼리는 노이즈만 일치하는 문서를 필터링합니다. 우리는 최소 하나의 의미 있는 토큰과 일치하는 문서를 원합니다.
왜 이 공식일까요? 관련성을 위해 여러 요소의 균형을 맞춥니다. 정확한 매칭은 높은 점수를 받지만, 많은 토큰과 일치하는 문서도 마찬가지입니다. 긴 문서가 지배하지 않지만, 높은 품질의 매칭은 합니다.
의미 있는 가중치를 가진 토큰이 없으면 가중치 1로 재시도합니다 (엣지 케이스용).
ID를 문서로 변환
검색 서비스는 문서 ID와 점수를 가진 SearchResult 객체를 반환합니다:
하지만 실제 문서가 필요합니다. 저장소를 사용하여 변환합니다:
왜 순서를 유지할까요? 검색 결과는 관련성 점수로 정렬됩니다. 결과를 표시할 때 그 순서를 유지하고 싶습니다.
저장소 메서드는 변환을 처리합니다:
FIELD() 함수는 ID 배열의 순서를 유지하므로 문서가 검색 결과와 같은 순서로 나타납니다.
결과: 무엇을 얻을까요
얻는 것은 다음을 수행하는 검색 엔진입니다:
- 관련 결과를 빠르게 찾습니다 (데이터베이스 인덱스 활용)
- 오타를 처리합니다 (n-gram이 부분 매칭을 잡음)
- 정확한 매칭을 우선시합니다 (단어 토크나이저가 가장 높은 가중치)
- 기존 데이터베이스와 함께 작동합니다 (외부 서비스 없음)
- 이해하고 디버깅하기 쉽습니다 (모든 것이 투명함)
- 동작에 대한 완전한 제어 (가중치 조정, 토크나이저 추가, 점수 매기기 수정)
시스템 확장
새로운 토크나이저를 추가하고 싶으신가요? TokenizerInterface를 구현하세요:
서비스 구성에 등록하면 인덱싱과 검색 모두에 자동으로 사용됩니다.
새로운 문서 타입을 추가하고 싶으신가요? IndexableDocumentInterface를 구현하세요:
가중치를 조정하고 싶으신가요? 구성을 변경하세요. 점수 매기기를 수정하고 싶으신가요? SQL 쿼리를 편집하세요. 모든 것이 당신의 제어 하에 있습니다.
결론
자, 여기 있습니다. 실제로 작동하는 간단한 검색 엔진. 화려하지 않고, 많은 인프라가 필요하지 않지만, 대부분의 사용 사례에 완벽합니다.
핵심 통찰? 때로는 최고의 해결책은 당신이 이해하는 것입니다. 마법이 없고, 블랙박스가 없고, 단지 자신이 하는 일을 말하는 간단한 코드입니다.
당신이 소유하고, 제어하고, 디버깅할 수 있습니다. 그리고 그것은 많은 가치가 있습니다.