실제로 작동하는 간단한 검색 엔진 만들기

I
Inkyu Oh

Back-End2025.11.19

karboosx 블로그 포스트 번역
2025-11-09



왜 직접 만들까?

보세요, 당신이 무슨 생각을 하는지 알아요. "Elasticsearch를 쓰면 안 되나?" 또는 "Algolia는 어때?" 이런 생각 말이에요. 이것들은 유효한 선택지지만, 복잡성이 따릅니다. API를 배워야 하고, 인프라를 관리해야 하고, 그들의 특이한 점들을 다루어야 합니다.
때로는 다음과 같은 것이 필요할 뿐입니다:
  • 기존 데이터베이스와 함께 작동
  • 외부 서비스가 필요 없음
  • 이해하고 디버깅하기 쉬움
  • 실제로 관련 결과를 찾음
이것이 내가 만든 것입니다. 기존 데이터베이스를 사용하고, 현재 아키텍처를 존중하며, 작동 방식에 대한 완전한 제어를 제공하는 검색 엔진입니다.



핵심 아이디어

개념은 간단합니다: 모든 것을 토큰화하고, 저장한 다음, 검색할 때 토큰을 매칭합니다.
작동 방식은 다음과 같습니다:
  1. 인덱싱: 콘텐츠를 추가하거나 업데이트할 때, 텍스트를 토큰(단어, 접두사, n-gram)으로 분할하고 가중치와 함께 저장합니다
  1. 검색: 누군가 검색할 때, 쿼리를 같은 방식으로 토큰화하고, 일치하는 토큰을 찾아 결과를 점수 매깁니다
  1. 점수 매기기: 저장된 가중치를 사용하여 관련성 점수를 계산합니다
마법은 토큰화와 가중치 부여에 있습니다. 제가 보여드리겠습니다.



구성 요소 1: 데이터베이스 스키마

두 개의 간단한 테이블이 필요합니다: index_tokensindex_entries.

index_tokens

이 테이블은 모든 고유 토큰을 토크나이저 가중치와 함께 저장합니다. 각 토큰 이름은 여러 레코드를 가질 수 있으며, 각각 다른 가중치를 가집니다 - 토크나이저당 하나씩.
// index_tokens 테이블 구조
id | name | weight
----|---------|--------
1 | parser | 20 // WordTokenizer에서
2 | parser | 5 // PrefixTokenizer에서
3 | parser | 1 // NGramsTokenizer에서
4 | parser | 10 // SingularTokenizer에서
왜 가중치별로 별도의 토큰을 저장할까요? 다른 토크나이저가 같은 토큰을 다른 가중치로 생성합니다. 예를 들어, WordTokenizer의 "parser"는 가중치 20을 가지지만, PrefixTokenizer의 "parser"는 가중치 5를 가집니다. 일치를 올바르게 점수 매기려면 별도의 레코드가 필요합니다.
고유 제약은 (name, weight)에 있으므로, 같은 토큰 이름이 다른 가중치로 여러 번 존재할 수 있습니다.

index_entries

이 테이블은 토큰을 필드별 가중치가 있는 문서와 연결합니다.
// index_entries 테이블 구조
id | token_id | document_type | field_id | document_id | weight
----|----------|---------------|----------|-------------|-------
1 | 1 | 1 | 1 | 42 | 2000
2 | 2 | 1 | 1 | 42 | 500
여기서 weight는 최종 계산된 가중치입니다: field_weight × tokenizer_weight × ceil(sqrt(token_length)). 이것은 점수 매기기에 필요한 모든 것을 인코딩합니다. 점수 매기기에 대해서는 나중에 포스트에서 이야기하겠습니다.
인덱스를 추가합니다:
  • (document_type, document_id) - 빠른 문서 조회
  • token_id - 빠른 토큰 조회
  • (document_type, field_id) - 필드별 쿼리
  • weight - 가중치별 필터링
왜 이 구조일까요? 간단하고 효율적이며, 데이터베이스가 잘하는 것을 활용합니다.



구성 요소 2: 토큰화

토큰화란 무엇일까요? 텍스트를 검색 가능한 조각으로 나누는 것입니다. "parser"라는 단어는 사용하는 토크나이저에 따라 ["parser"], ["par", "pars", "parse", "parser"], 또는 ["par", "ars", "rse", "ser"]과 같은 토큰이 됩니다.
왜 여러 토크나이저를 사용할까요? 다른 매칭 필요에 따른 다른 전략입니다. 정확한 매칭을 위한 토크나이저, 부분 매칭을 위한 토크나이저, 오타를 위한 토크나이저.
모든 토크나이저는 간단한 인터페이스를 구현합니다:
interface TokenizerInterface
{
public function tokenize(string $text): array; // Token 객체 배열 반환
public function getWeight(): int; // 토크나이저 가중치 반환
}
간단한 계약, 확장하기 쉽습니다.

Word Tokenizer

이것은 간단합니다 - 텍스트를 개별 단어로 분할합니다. "parser"는 단순히 ["parser"]가 됩니다. 간단하지만 정확한 매칭에 강력합니다.
먼저 텍스트를 정규화합니다. 모든 것을 소문자로, 특수 문자 제거, 공백 정규화:
class WordTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// 정규화: 소문자, 특수 문자 제거
$text = mb_strtolower(trim($text));
$text = preg_replace('/[^a-z0-9]/', ' ', $text);
$text = preg_replace('/\s+/', ' ', $text);
다음으로 단어로 분할하고 짧은 것들을 필터링합니다:
// 단어로 분할, 짧은 것 필터링
$words = explode(' ', $text);
$words = array_filter($words, fn($w) => mb_strlen($w) >= 2);
왜 짧은 단어를 필터링할까요? 한 글자 단어는 보통 검색에 유용하기에는 너무 흔합니다. "a", "I", "x"는 검색에 도움이 되지 않습니다.
마지막으로 고유한 단어를 Token 객체로 반환합니다:
// Token 객체로 가중치와 함께 반환
return array_map(
fn($word) => new Token($word, $this->weight),
array_unique($words)
);
}
}
가중치: 20 (정확한 매칭에 높은 우선순위)

Prefix Tokenizer

이것은 단어 접두사를 생성합니다. "parser"는 ["par", "pars", "parse", "parser"]가 됩니다 (최소 길이 4). 이것은 부분 매칭과 자동완성 같은 동작을 도와줍니다.
먼저 단어를 추출합니다 (WordTokenizer와 같은 정규화):
class PrefixTokenizer implements TokenizerInterface
{
public function __construct(
private int $minPrefixLength = 4,
private int $weight = 5
) {}
public function tokenize(string $text): array
{
// WordTokenizer와 같이 정규화
$words = $this->extractWords($text);
그 다음, 각 단어에 대해 최소 길이에서 전체 단어까지의 접두사를 생성합니다:
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// 최소 길이에서 전체 단어까지 접두사 생성
for ($i = $this->minPrefixLength; $i <= $wordLength; $i++) {
$prefix = mb_substr($word, 0, $i);
$tokens[$prefix] = true; // 고유성을 위해 연관 배열 사용
}
}
왜 연관 배열을 사용할까요? 고유성을 보장합니다. "parser"가 텍스트에 두 번 나타나면, "parser" 토큰은 하나만 원합니다.
마지막으로 키를 Token 객체로 변환합니다:
return array_map(
fn($prefix) => new Token($prefix, $this->weight),
array_keys($tokens)
);
}
}
가중치: 5 (중간 우선순위)
왜 최소 길이일까요? 너무 많은 작은 토큰을 피하기 위해. 4자 미만의 접두사는 보통 유용하기에는 너무 흔합니다.

N-Grams Tokenizer

이것은 고정 길이의 문자 시퀀스를 만듭니다 (저는 3을 사용합니다). "parser"는 ["par", "ars", "rse", "ser"]이 됩니다. 이것은 오타와 부분 단어 매칭을 잡습니다.
먼저 단어를 추출합니다:
class NGramsTokenizer implements TokenizerInterface
{
public function __construct(
private int $ngramLength = 3,
private int $weight = 1
) {}
public function tokenize(string $text): array
{
$words = $this->extractWords($text);
그 다음, 각 단어에 대해 고정 길이의 윈도우를 슬라이드합니다:
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// 고정 길이의 슬라이딩 윈도우
for ($i = 0; $i <= $wordLength - $this->ngramLength; $i++) {
$ngram = mb_substr($word, $i, $this->ngramLength);
$tokens[$ngram] = true;
}
}
슬라이딩 윈도우: 길이 3인 "parser"의 경우:
  • 위치 0: "par"
  • 위치 1: "ars"
  • 위치 2: "rse"
  • 위치 3: "ser"
왜 이것이 작동할까요? 누군가 "parsr"을 입력해도 (오타), 우리는 여전히 "par"과 "ars" 토큰을 얻으며, 이는 올바르게 철자된 "parser"와 일치합니다.
마지막으로 Token 객체로 변환합니다:
return array_map(
fn($ngram) => new Token($ngram, $this->weight),
array_keys($tokens)
);
}
}
가중치: 1 (낮은 우선순위, 하지만 엣지 케이스를 잡음)
왜 3일까요? 커버리지와 노이즈 사이의 균형. 너무 짧으면 너무 많은 매칭을 얻고, 너무 길면 오타를 놓칩니다.

정규화

모든 토크나이저는 같은 정규화를 수행합니다:
  • 모든 것을 소문자로
  • 특수 문자 제거 (영숫자만 유지)
  • 공백 정규화 (여러 공백을 단일 공백으로)
이것은 입력 형식에 관계없이 일관된 매칭을 보장합니다.



구성 요소 3: 가중치 시스템

세 가지 수준의 가중치가 함께 작동합니다:
  1. 필드 가중치: 제목 vs 콘텐츠 vs 키워드
  1. 토크나이저 가중치: 단어 vs 접두사 vs n-gram (index_tokens에 저장)
  1. 문서 가중치: index_entries에 저장 (계산: field_weight × tokenizer_weight × ceil(sqrt(token_length)))

최종 가중치 계산

인덱싱할 때, 최종 가중치를 다음과 같이 계산합니다:
$finalWeight = $fieldWeight * $tokenizerWeight * ceil(sqrt($tokenLength));
예를 들어:
  • 제목 필드: 가중치 10
  • Word tokenizer: 가중치 20
  • 토큰 "parser": 길이 6
  • 최종 가중치: 10 × 20 × ceil(sqrt(6)) = 10 × 20 × 3 = 600
ceil(sqrt())를 사용할까요? 더 긴 토큰이 더 구체적이지만, 매우 긴 토큰으로 가중치가 폭발하는 것을 원하지 않습니다. "parser"는 "par"보다 더 구체적이지만, 100자 토큰이 100배의 가중치를 가져서는 안 됩니다. 제곱근 함수는 우리에게 수확 체감을 제공합니다 - 더 긴 토큰은 여전히 더 높은 점수를 받지만, 선형적이지 않습니다. 우리는 ceil()을 사용하여 가장 가까운 정수로 올림하고, 가중치를 정수로 유지합니다.

가중치 조정

사용 사례에 맞게 가중치를 조정할 수 있습니다:
  • 제목이 가장 중요하면 필드 가중치를 증가시킵니다
  • 정확한 매칭을 우선시하려면 토크나이저 가중치를 증가시킵니다
  • 더 긴 토큰이 더 중요하거나 덜 중요하도록 하려면 토큰 길이 함수(ceil(sqrt), log, 또는 선형)를 조정합니다
가중치가 어떻게 계산되는지 정확히 볼 수 있고 필요에 따라 조정할 수 있습니다.



구성 요소 4: 인덱싱 서비스

인덱싱 서비스는 문서를 가져와 모든 토큰을 데이터베이스에 저장합니다.

인터페이스

인덱싱할 수 있는 문서는 IndexableDocumentInterface를 구현합니다:
interface IndexableDocumentInterface
{
public function getDocumentId(): int;
public function getDocumentType(): DocumentType;
public function getIndexableFields(): IndexableFields;
}
문서를 검색 가능하게 만들려면 이 세 가지 메서드를 구현합니다:
class Post implements IndexableDocumentInterface
{
public function getDocumentId(): int
{
return $this->id ?? 0;
}
public function getDocumentType(): DocumentType
{
return DocumentType::POST;
}
public function getIndexableFields(): IndexableFields
{
$fields = IndexableFields::create()
->addField(FieldId::TITLE, $this->title ?? '', 10)
->addField(FieldId::CONTENT, $this->content ?? '', 1);
// 키워드가 있으면 추가
if (!empty($this->keywords)) {
$fields->addField(FieldId::KEYWORDS, $this->keywords, 20);
}
return $fields;
}
}
구현할 세 가지 메서드:
  • getDocumentType(): 문서 타입 enum 반환
  • getDocumentId(): 문서 ID 반환
  • getIndexableFields(): fluent API를 사용하여 가중치가 있는 필드 구축
문서를 인덱싱할 수 있습니다:
  • 생성/업데이트 시 (이벤트 리스너를 통해)
  • 명령어를 통해: app:index-document, app:reindex-documents
  • cron을 통해 (배치 재인덱싱)

작동 방식

인덱싱 프로세스, 단계별로.
먼저 문서 정보를 가져옵니다:
class SearchIndexingService
{
public function indexDocument(IndexableDocumentInterface $document): void
{
// 1. 문서 정보 가져오기
$documentType = $document->getDocumentType();
$documentId = $document->getDocumentId();
$indexableFields = $document->getIndexableFields();
$fields = $indexableFields->getFields();
$weights = $indexableFields->getWeights();
문서는 IndexableFields 빌더를 통해 필드와 가중치를 제공합니다.
다음으로 이 문서의 기존 인덱스를 제거합니다. 이것은 업데이트를 처리합니다 - 문서가 변경되면 재인덱싱해야 합니다:
// 2. 이 문서의 기존 인덱스 제거
$this->removeDocumentIndex($documentType, $documentId);
// 3. 배치 삽입 데이터 준비
$insertData = [];
왜 먼저 제거할까요? 새 토큰만 추가하면 중복이 생깁니다. 처음부터 시작하는 것이 낫습니다.
이제 각 필드를 처리합니다. 각 필드에 대해 모든 토크나이저를 실행합니다:
// 4. 각 필드 처리
foreach ($fields as $fieldIdValue => $content) {
if (empty($content)) {
continue;
}
$fieldId = FieldId::from($fieldIdValue);
$fieldWeight = $weights[$fieldIdValue] ?? 0;
// 5. 이 필드에 모든 토크나이저 실행
foreach ($this->tokenizers as $tokenizer) {
$tokens = $tokenizer->tokenize($content);
각 토크나이저에 대해 토큰을 얻습니다. 그 다음, 각 토큰에 대해 데이터베이스에서 찾거나 생성하고 최종 가중치를 계산합니다:
foreach ($tokens as $token) {
$tokenValue = $token->value;
$tokenWeight = $token->weight;
// 6. index_tokens에서 토큰 찾거나 생성
$tokenId = $this->findOrCreateToken($tokenValue, $tokenWeight);
// 7. 최종 가중치 계산
$tokenLength = mb_strlen($tokenValue);
$finalWeight = (int) ($fieldWeight * $tokenWeight * ceil(sqrt($tokenLength)));
// 8. 배치 삽입에 추가
$insertData[] = [
'token_id' => $tokenId,
'document_type' => $documentType->value,
'field_id' => $fieldId->value,
'document_id' => $documentId,
'weight' => $finalWeight,
];
}
}
}
왜 배치 삽입일까요? 성능. 한 번에 한 행씩 삽입하는 대신, 모든 행을 수집하고 한 번의 쿼리로 삽입합니다.
마지막으로 모든 것을 배치 삽입합니다:
// 9. 성능을 위해 배치 삽입
if (!empty($insertData)) {
$this->batchInsertSearchDocuments($insertData);
}
}
findOrCreateToken 메서드는 간단합니다:
private function findOrCreateToken(string $name, int $weight): int
{
// 같은 이름과 가중치를 가진 기존 토큰 찾기
$sql = "SELECT id FROM index_tokens WHERE name = ? AND weight = ?";
$result = $this->connection->executeQuery($sql, [$name, $weight])->fetchAssociative();
if ($result) {
return (int) $result['id'];
}
// 새 토큰 생성
$insertSql = "INSERT INTO index_tokens (name, weight) VALUES (?, ?)";
$this->connection->executeStatement($insertSql, [$name, $weight]);
return (int) $this->connection->lastInsertId();
}
}
왜 찾거나 생성할까요? 토큰은 문서 간에 공유됩니다. "parser"가 이미 가중치 20으로 존재하면 재사용합니다. 중복을 만들 필요가 없습니다.
핵심 포인트:
  • 먼저 이전 인덱스를 제거합니다 (업데이트 처리)
  • 성능을 위해 배치 삽입합니다 (많은 쿼리 대신 하나)
  • 토큰을 찾거나 생성합니다 (중복 방지)
  • 즉시 최종 가중치를 계산합니다



구성 요소 5: 검색 서비스

검색 서비스는 쿼리 문자열을 가져와 관련 문서를 찾습니다. 인덱싱 중에 문서를 토큰화한 것과 같은 방식으로 쿼리를 토큰화한 다음, 이 토큰들을 데이터베이스의 인덱싱된 토큰과 매칭합니다. 결과는 관련성으로 점수 매겨지고 문서 ID와 점수로 반환됩니다.

작동 방식

검색 프로세스, 단계별로.
먼저 모든 토크나이저를 사용하여 쿼리를 토큰화합니다:
class SearchService
{
public function search(DocumentType $documentType, string $query, ?int $limit = null): array
{
// 1. 모든 토크나이저를 사용하여 쿼리 토큰화
$queryTokens = $this->tokenizeQuery($query);
if (empty($queryTokens)) {
return [];
}
쿼리가 토큰을 생성하지 않으면 (예: 특수 문자만), 빈 결과를 반환합니다.

왜 같은 토크나이저를 사용하여 쿼리를 토큰화할까요?

다른 토크나이저는 다른 토큰 값을 생성합니다. 하나의 세트로 인덱싱하고 다른 것으로 검색하면 매칭을 놓칩니다.
예:
  • PrefixTokenizer로 인덱싱하면 토큰이 생성됩니다: "par", "pars", "parse", "parser"
  • WordTokenizer만으로 검색하면 토큰이 생성됩니다: "parser"
  • "parser"는 찾을 수 있지만, "par" 또는 "pars" 토큰만 있는 문서는 찾을 수 없습니다
  • 결과: 불완전한 매칭, 관련 문서 누락!
해결책: 인덱싱과 검색 모두에 같은 토크나이저를 사용합니다. 같은 토큰화 전략 = 같은 토큰 값 = 완전한 매칭.
이것이 SearchServiceSearchIndexingService 모두 같은 토크나이저 세트를 받는 이유입니다.
다음으로 고유한 토큰 값을 추출합니다. 여러 토크나이저가 같은 토큰 값을 생성할 수 있으므로 중복 제거합니다:
// 2. 고유한 토큰 값 추출
$tokenValues = array_unique(array_map(
fn($token) => $token instanceof Token ? $token->value : $token,
$queryTokens
));
왜 값을 추출할까요? 토큰 이름으로 검색합니다. 검색하려면 고유한 토큰 이름이 필요합니다.
그 다음, 토큰을 길이순으로 정렬합니다 (가장 긴 것부터). 이것은 구체적인 매칭을 우선시합니다:
// 3. 토큰 정렬 (가장 긴 것부터 - 구체적인 매칭 우선)
usort($tokenValues, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a));
왜 정렬할까요? 더 긴 토큰이 더 구체적입니다. "parser"는 "par"보다 더 구체적이므로 "parser"를 먼저 검색하고 싶습니다.
또한 거대한 쿼리로 인한 DoS 공격을 방지하기 위해 토큰 수를 제한합니다:
// 4. 토큰 수 제한 (거대한 쿼리로 인한 DoS 방지)
if (count($tokenValues) > 300) {
$tokenValues = array_slice($tokenValues, 0, 300);
}
왜 제한할까요? 악의적인 사용자가 수천 개의 토큰을 생성하는 쿼리를 보낼 수 있으며, 이는 성능 문제를 일으킵니다. 가장 긴 300개 토큰을 유지합니다 (이미 정렬됨).
이제 최적화된 SQL 쿼리를 실행합니다. executeSearch() 메서드는 SQL 쿼리를 구축하고 실행합니다:
// 5. 최적화된 SQL 쿼리 실행
$results = $this->executeSearch($documentType, $tokenValues, $limit);
executeSearch() 내에서 매개변수 자리 표시자가 있는 SQL 쿼리를 구축하고, 실행하고, 낮은 점수 결과를 필터링하고, SearchResult 객체로 변환합니다:
private function executeSearch(DocumentType $documentType, array $tokenValues, int $tokenCount, ?int $limit, int $minTokenWeight): array
{
// 토큰 값에 대한 매개변수 자리 표시자 구축
$tokenPlaceholders = implode(',', array_fill(0, $tokenCount, '?'));
// SQL 쿼리 구축 (아래 "SQL 쿼리" 섹션에서 전체 표시)
$sql = "SELECT sd.document_id, ... FROM index_entries sd ...";
// 매개변수 배열 구축
$params = [
$documentType->value, // document_type
...$tokenValues, // IN 절을 위한 토큰 값
$documentType->value, // 서브쿼리용
...$tokenValues, // 서브쿼리용 토큰 값
$minTokenWeight, // 최소 토큰 가중치
// ... 더 많은 매개변수
];
// 매개변수 바인딩으로 쿼리 실행
$results = $this->connection->executeQuery($sql, $params)->fetchAllAssociative();
// 낮은 정규화 점수를 가진 결과 필터링 (임계값 이하)
$results = array_filter($results, fn($r) => (float) $r['score'] >= 0.05);
// SearchResult 객체로 변환
return array_map(
fn($result) => new SearchResult(
documentId: (int) $result['document_id'],
score: (float) $result['score']
),
$results
);
}
SQL 쿼리는 무거운 작업을 수행합니다: 일치하는 문서를 찾고, 점수를 계산하고, 관련성으로 정렬합니다. 성능과 완전한 제어를 위해 원시 SQL을 사용합니다 - 필요한 대로 정확히 쿼리를 최적화할 수 있습니다.
쿼리는 토큰과 문서를 연결하는 JOIN, 정규화를 위한 서브쿼리, 점수 매기기를 위한 집계, 토큰 이름, 문서 타입, 가중치에 대한 인덱스를 사용합니다. 보안을 위해 매개변수 바인딩을 사용합니다 (SQL 주입 방지).
다음 섹션에서 전체 쿼리를 볼 것입니다.
주요 search() 메서드는 결과를 반환합니다:
// 5. 결과 반환
return $results;
}
}

점수 매기기 알고리즘

점수 매기기 알고리즘은 여러 요소의 균형을 맞춥니다. 단계별로 분석해봅시다.
기본 점수는 모든 일치하는 토큰 가중치의 합입니다:
SELECT
sd.document_id,
SUM(sd.weight) as base_score
FROM index_entries sd
INNER JOIN index_tokens st ON sd.token_id = st.id
WHERE
sd.document_type = ?
AND st.name IN (?, ?, ?) -- 쿼리 토큰
GROUP BY sd.document_id
  • sd.weight: index_entries에서 (field_weight × tokenizer_weight × ceil(sqrt(token_length)))
st.weight를 곱하지 않을까요? 토크나이저 가중치는 이미 인덱싱 중에 sd.weight에 포함되어 있습니다. index_tokensst.weight는 필터링을 위해서만 전체 SQL 쿼리의 WHERE 절에서 사용됩니다 (최소 하나의 토큰이 weight >= minTokenWeight를 가지도록 보장).
이것은 우리에게 원시 점수를 제공합니다. 하지만 더 필요합니다.
토큰 다양성 부스트를 추가합니다. 더 많은 고유 토큰과 일치하는 문서가 더 높은 점수를 받습니다:
(1.0 + LOG(1.0 + COUNT(DISTINCT sd.token_id))) * base_score
왜? 5개의 다른 토큰과 일치하는 문서는 같은 토큰 5번과 일치하는 것보다 더 관련성이 있습니다. LOG 함수는 이 부스트를 로그 함수로 만듭니다 - 10개 토큰과 일치하는 것이 10배 부스트를 주지는 않습니다.
또한 평균 가중치 품질 부스트를 추가합니다. 더 높은 품질의 매칭을 가진 문서가 더 높은 점수를 받습니다:
(1.0 + LOG(1.0 + AVG(sd.weight))) * base_score
왜? 높은 가중치 매칭(예: 제목 매칭)을 가진 문서는 낮은 가중치 매칭(예: 콘텐츠 매칭)을 가진 것보다 더 관련성이 있습니다. 다시 LOG는 로그 함수입니다.
문서 길이 페널티를 적용합니다. 긴 문서가 지배하는 것을 방지합니다:
base_score / (1.0 + LOG(1.0 + doc_token_count.token_count))
왜? 1000단어 문서가 자동으로 100단어 문서를 이기지는 않습니다. LOG 함수는 이 페널티를 로그 함수로 만듭니다 - 10배 더 긴 문서가 10배 페널티를 받지는 않습니다.
마지막으로 최대 점수로 나누어 정규화합니다:
score / GREATEST(1.0, max_score) as normalized_score
이것은 0-1 범위를 제공하여 다양한 쿼리 간에 점수를 비교할 수 있게 합니다.
전체 공식은 다음과 같습니다:
SELECT
sd.document_id,
(
SUM(sd.weight) * -- 기본 점수
(1.0 + LOG(1.0 + COUNT(DISTINCT sd.token_id))) * -- 토큰 다양성 부스트
(1.0 + LOG(1.0 + AVG(sd.weight))) / -- 평균 가중치 품질 부스트
(1.0 + LOG(1.0 + doc_token_count.token_count)) -- 문서 길이 페널티
) / GREATEST(1.0, max_score) as score -- 정규화
FROM index_entries sd
INNER JOIN index_tokens st ON sd.token_id = st.id
INNER JOIN (
SELECT document_id, COUNT(*) as token_count
FROM index_entries
WHERE document_type = ?
GROUP BY document_id
) doc_token_count ON sd.document_id = doc_token_count.document_id
WHERE
sd.document_type = ?
AND st.name IN (?, ?, ?) -- 쿼리 토큰
AND sd.document_id IN (
SELECT DISTINCT document_id
FROM index_entries sd2
INNER JOIN index_tokens st2 ON sd2.token_id = st2.id
WHERE sd2.document_type = ?
AND st2.name IN (?, ?, ?)
AND st2.weight >= ? -- 의미 있는 가중치를 가진 최소 하나의 토큰 보장
)
GROUP BY sd.document_id
ORDER BY score DESC
LIMIT ?
st2.weight >= ?를 가진 서브쿼리일까요? 이것은 의미 있는 토크나이저 가중치를 가진 최소 하나의 일치하는 토큰을 가진 문서만 포함하도록 보장합니다. 이 필터 없이, 낮은 우선순위 토큰(예: 가중치 1인 n-gram)만 일치하는 문서도 포함되며, 높은 우선순위 토큰(예: 가중치 20인 단어)과 일치하지 않습니다. 이 서브쿼리는 노이즈만 일치하는 문서를 필터링합니다. 우리는 최소 하나의 의미 있는 토큰과 일치하는 문서를 원합니다.
왜 이 공식일까요? 관련성을 위해 여러 요소의 균형을 맞춥니다. 정확한 매칭은 높은 점수를 받지만, 많은 토큰과 일치하는 문서도 마찬가지입니다. 긴 문서가 지배하지 않지만, 높은 품질의 매칭은 합니다.
의미 있는 가중치를 가진 토큰이 없으면 가중치 1로 재시도합니다 (엣지 케이스용).

ID를 문서로 변환

검색 서비스는 문서 ID와 점수를 가진 SearchResult 객체를 반환합니다:
class SearchResult
{
public function __construct(
public readonly int $documentId,
public readonly float $score
) {}
}
하지만 실제 문서가 필요합니다. 저장소를 사용하여 변환합니다:
// 검색 수행
$searchResults = $this->searchService->search(
DocumentType::POST,
$query,
$limit
);

// 검색 결과에서 문서 ID 가져오기 (순서 유지)
$documentIds = array_map(fn($result) => $result->documentId, $searchResults);

// ID로 문서 가져오기 (검색 결과의 순서 유지)
$documents = $this->documentRepository->findByIds($documentIds);
왜 순서를 유지할까요? 검색 결과는 관련성 점수로 정렬됩니다. 결과를 표시할 때 그 순서를 유지하고 싶습니다.
저장소 메서드는 변환을 처리합니다:
public function findByIds(array $ids): array
{
if (empty($ids)) {
return [];
}
return $this->createQueryBuilder('d')
->where('d.id IN (:ids)')
->setParameter('ids', $ids)
->orderBy('FIELD(d.id, :ids)') // ID 배열의 순서 유지
->getQuery()
->getResult();
}
FIELD() 함수는 ID 배열의 순서를 유지하므로 문서가 검색 결과와 같은 순서로 나타납니다.



결과: 무엇을 얻을까요

얻는 것은 다음을 수행하는 검색 엔진입니다:
  • 관련 결과를 빠르게 찾습니다 (데이터베이스 인덱스 활용)
  • 오타를 처리합니다 (n-gram이 부분 매칭을 잡음)
  • 부분 단어를 처리합니다 (접두사 토크나이저)
  • 정확한 매칭을 우선시합니다 (단어 토크나이저가 가장 높은 가중치)
  • 기존 데이터베이스와 함께 작동합니다 (외부 서비스 없음)
  • 이해하고 디버깅하기 쉽습니다 (모든 것이 투명함)
  • 동작에 대한 완전한 제어 (가중치 조정, 토크나이저 추가, 점수 매기기 수정)



시스템 확장

새로운 토크나이저를 추가하고 싶으신가요? TokenizerInterface를 구현하세요:
class StemmingTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// 여기에 스테밍 로직
// Token 객체 배열 반환
}
public function getWeight(): int
{
return 15; // 당신의 가중치
}
}
서비스 구성에 등록하면 인덱싱과 검색 모두에 자동으로 사용됩니다.
새로운 문서 타입을 추가하고 싶으신가요? IndexableDocumentInterface를 구현하세요:
class Comment implements IndexableDocumentInterface
{
public function getIndexableFields(): IndexableFields
{
return IndexableFields::create()
->addField(FieldId::CONTENT, $this->content ?? '', 5);
}
}
가중치를 조정하고 싶으신가요? 구성을 변경하세요. 점수 매기기를 수정하고 싶으신가요? SQL 쿼리를 편집하세요. 모든 것이 당신의 제어 하에 있습니다.



결론

자, 여기 있습니다. 실제로 작동하는 간단한 검색 엔진. 화려하지 않고, 많은 인프라가 필요하지 않지만, 대부분의 사용 사례에 완벽합니다.
핵심 통찰? 때로는 최고의 해결책은 당신이 이해하는 것입니다. 마법이 없고, 블랙박스가 없고, 단지 자신이 하는 일을 말하는 간단한 코드입니다.
당신이 소유하고, 제어하고, 디버깅할 수 있습니다. 그리고 그것은 많은 가치가 있습니다.
0
14

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!