index_tokens과 index_entries.// index_tokens 테이블 구조id | name | weight----|---------|--------1 | parser | 20 // WordTokenizer에서2 | parser | 5 // PrefixTokenizer에서3 | parser | 1 // NGramsTokenizer에서4 | parser | 10 // SingularTokenizer에서(name, weight)에 있으므로, 같은 토큰 이름이 다른 가중치로 여러 번 존재할 수 있습니다.// index_entries 테이블 구조id | token_id | document_type | field_id | document_id | weight----|----------|---------------|----------|-------------|-------1 | 1 | 1 | 1 | 42 | 20002 | 2 | 1 | 1 | 42 | 500weight는 최종 계산된 가중치입니다: field_weight × tokenizer_weight × ceil(sqrt(token_length)). 이것은 점수 매기기에 필요한 모든 것을 인코딩합니다. 점수 매기기에 대해서는 나중에 포스트에서 이야기하겠습니다.(document_type, document_id) - 빠른 문서 조회token_id - 빠른 토큰 조회(document_type, field_id) - 필드별 쿼리weight - 가중치별 필터링["parser"], ["par", "pars", "parse", "parser"], 또는 ["par", "ars", "rse", "ser"]과 같은 토큰이 됩니다.interface TokenizerInterface{ public function tokenize(string $text): array; // Token 객체 배열 반환 public function getWeight(): int; // 토크나이저 가중치 반환}["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); // Token 객체로 가중치와 함께 반환 return array_map( fn($word) => new Token($word, $this->weight), array_unique($words) ); }}["par", "pars", "parse", "parser"]가 됩니다 (최소 길이 4). 이것은 부분 매칭과 자동완성 같은 동작을 도와줍니다.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; // 고유성을 위해 연관 배열 사용 } } return array_map( fn($prefix) => new Token($prefix, $this->weight), array_keys($tokens) ); }}["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; } } return array_map( fn($ngram) => new Token($ngram, $this->weight), array_keys($tokens) ); }}field_weight × tokenizer_weight × ceil(sqrt(token_length)))$finalWeight = $fieldWeight * $tokenizerWeight * ceil(sqrt($tokenLength));10 × 20 × ceil(sqrt(6)) = 10 × 20 × 3 = 600ceil(sqrt())를 사용할까요? 더 긴 토큰이 더 구체적이지만, 매우 긴 토큰으로 가중치가 폭발하는 것을 원하지 않습니다. "parser"는 "par"보다 더 구체적이지만, 100자 토큰이 100배의 가중치를 가져서는 안 됩니다. 제곱근 함수는 우리에게 수확 체감을 제공합니다 - 더 긴 토큰은 여전히 더 높은 점수를 받지만, 선형적이지 않습니다. 우리는 ceil()을 사용하여 가장 가까운 정수로 올림하고, 가중치를 정수로 유지합니다.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-documentsclass 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(); }}class SearchService{ public function search(DocumentType $documentType, string $query, ?int $limit = null): array { // 1. 모든 토크나이저를 사용하여 쿼리 토큰화 $queryTokens = $this->tokenizeQuery($query); if (empty($queryTokens)) { return []; }SearchService와 SearchIndexingService 모두 같은 토크나이저 세트를 받는 이유입니다. // 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)); // 4. 토큰 수 제한 (거대한 쿼리로 인한 DoS 방지) if (count($tokenValues) > 300) { $tokenValues = array_slice($tokenValues, 0, 300); }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 );}search() 메서드는 결과를 반환합니다: // 5. 결과 반환 return $results; }}SELECT sd.document_id, SUM(sd.weight) as base_scoreFROM index_entries sdINNER JOIN index_tokens st ON sd.token_id = st.idWHERE sd.document_type = ? AND st.name IN (?, ?, ?) -- 쿼리 토큰GROUP BY sd.document_idsd.weight: index_entries에서 (field_weight × tokenizer_weight × ceil(sqrt(token_length)))st.weight를 곱하지 않을까요? 토크나이저 가중치는 이미 인덱싱 중에 sd.weight에 포함되어 있습니다. index_tokens의 st.weight는 필터링을 위해서만 전체 SQL 쿼리의 WHERE 절에서 사용됩니다 (최소 하나의 토큰이 weight >= minTokenWeight를 가지도록 보장).(1.0 + LOG(1.0 + COUNT(DISTINCT sd.token_id))) * base_score(1.0 + LOG(1.0 + AVG(sd.weight))) * base_scorebase_score / (1.0 + LOG(1.0 + doc_token_count.token_count))score / GREATEST(1.0, max_score) as normalized_scoreSELECT 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 sdINNER JOIN index_tokens st ON sd.token_id = st.idINNER 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_idWHERE 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_idORDER BY score DESCLIMIT ?st2.weight >= ?를 가진 서브쿼리일까요? 이것은 의미 있는 토크나이저 가중치를 가진 최소 하나의 일치하는 토큰을 가진 문서만 포함하도록 보장합니다. 이 필터 없이, 낮은 우선순위 토큰(예: 가중치 1인 n-gram)만 일치하는 문서도 포함되며, 높은 우선순위 토큰(예: 가중치 20인 단어)과 일치하지 않습니다. 이 서브쿼리는 노이즈만 일치하는 문서를 필터링합니다. 우리는 최소 하나의 의미 있는 토큰과 일치하는 문서를 원합니다.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 배열의 순서를 유지하므로 문서가 검색 결과와 같은 순서로 나타납니다.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); }}아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!

Boolean을 넘어서
Inkyu Oh • Front-End

어떤 버그든 고치는 방법
Inkyu Oh • Front-End

LLM에서의 인지적 프롬프팅
Inkyu Oh • AI & ML-ops

AI를 언제 신뢰해야 할까? 매직 8볼 사고방식
Inkyu Oh • AI & ML-ops