ในโลกของ AI-powered search นั้น ไม่มีวิธีใดที่สมบูรณ์แบบสำหรับทุก use case การค้นหาด้วย Vector อย่างเดียวมักพลาดความหมายเชิงความถี่ของคำ (term frequency) ในขณะที่ Keyword Search อย่างเดียวไม่สามารถจับ semantic similarity ได้ Hybrid Search จึงเป็นคำตอบที่เหมาะสมที่สุดสำหรับ production system ที่ต้องการทั้งความแม่นยำและความเข้าใจเชิงความหมาย

ทำไมต้อง Hybrid Search?

จากประสบการณ์ในการสร้าง RAG system หลายตัว พบว่า pure vector search นั้นมีข้อจำกัดที่สำคัญ โดยเฉพาะกับข้อมูลที่มี:

สถาปัตยกรรม Hybrid Search

สถาปัตยกรรมที่ผมใช้งานจริงใน production ประกอบด้วย 3 components หลัก:

┌─────────────────────────────────────────────────────────────────┐
│                    Hybrid Search Architecture                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   User Query ──▶ Query Preprocessing                           │
│                       │                                         │
│                       ├──▶ Vector Search (embedding)            │
│                       │         │                               │
│                       │         ▼                               │
│                       │   [Pinecone/Milvus/Weaviate]            │
│                       │         │                               │
│                       │         ▼                               │
│                       │   Vector Scores (0-1)                  │
│                       │                                         │
│                       ├──▶ Keyword Search (BM25/rerank)         │
│                       │         │                               │
│                       │         ▼                               │
│                       │   BM25 Scores (normalized)             │
│                       │                                         │
│                       ▼                                         │
│              Reciprocal Rank Fusion (RRF)                       │
│                       │                                         │
│                       ▼                                         │
│              Final Ranked Results                               │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

การ Implement ด้วย HolySheep AI

สำหรับ embedding model ผมเลือกใช้ HolySheep AI เพราะมี latency ต่ำกว่า 50ms และราคาถูกกว่า OpenAI ถึง 85%+ ทำให้ cost ของ hybrid search infrastructure ลดลงอย่างมาก

import requests
import numpy as np
from typing import List, Dict, Tuple
from dataclasses import dataclass

HolySheep AI Configuration

HOLYSHEEP_BASE_URL = "https://api.holysheep.ai/v1" HOLYSHEEP_API_KEY = "YOUR_HOLYSHEEP_API_KEY" @dataclass class SearchResult: doc_id: str content: str vector_score: float bm25_score: float fused_score: float metadata: Dict class HybridSearcher: def __init__( self, api_key: str, vector_store, # Pinecone, Milvus, หรือ Weaviate client bm25_index, # RankBM25 หรือ Elasticsearch embedding_model: str = "text-embedding-3-small", vector_weight: float = 0.6, keyword_weight: float = 0.4 ): self.api_key = api_key self.vector_store = vector_store self.bm25_index = bm25_index self.embedding_model = embedding_model self.vector_weight = vector_weight self.keyword_weight = keyword_weight def _get_embedding(self, text: str) -> List[float]: """Generate embedding via HolySheep AI (<50ms latency)""" response = requests.post( f"{HOLYSHEEP_BASE_URL}/embeddings", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" }, json={ "model": self.embedding_model, "input": text }, timeout=10 ) response.raise_for_status() return response.json()["data"][0]["embedding"] def _normalize_scores(self, scores: List[float]) -> List[float]: """Min-Max normalization to [0, 1] range""" if not scores or max(scores) == min(scores): return [1.0] * len(scores) min_s, max_s = min(scores), max(scores) return [(s - min_s) / (max_s - min_s) for s in scores] def _reciprocal_rank_fusion( self, results: List[Tuple[str, float]], k: int = 60 ) -> Dict[str, float]: """ Reciprocal Rank Fusion (RRF) algorithm Combines multiple ranking lists into a single ranking """ rrf_scores = {} for rank, (doc_id, score) in enumerate(results, 1): if doc_id not in rrf_scores: rrf_scores[doc_id] = 0.0 rrf_scores[doc_id] += 1.0 / (k + rank) return rrf_scores def search(self, query: str, top_k: int = 20) -> List[SearchResult]: # Step 1: Generate embedding query_embedding = self._get_embedding(query) # Step 2: Vector search vector_results = self.vector_store.query( vector=query_embedding, top_k=top_k * 2, # Fetch more for fusion include_scores=True ) vector_scores = { match["id"]: match["score"] for match in vector_results["matches"] } # Step 3: Keyword/BM25 search bm25_results = self.bm25_index.search(query) bm25_scores = { doc.doc_id: doc.score for doc in bm25_results[:top_k * 2] } # Step 4: Normalize scores all_doc_ids = set(vector_scores.keys()) | set(bm25_scores.keys()) norm_vector = self._normalize_scores( [vector_scores.get(d, 0) for d in all_doc_ids] ) norm_bm25 = self._normalize_scores( [bm25_scores.get(d, 0) for d in all_doc_ids] ) # Step 5: Apply weights and RRF fusion weighted_scores = [] for i, doc_id in enumerate(all_doc_ids): combined = ( self.vector_weight * norm_vector[i] + self.keyword_weight * norm_bm25[i] ) weighted_scores.append((doc_id, combined)) # RRF fusion fused = self._reciprocal_rank_fusion(weighted_scores) # Step 6: Sort and return top results sorted_results = sorted( fused.items(), key=lambda x: x[1], reverse=True )[:top_k] return [ SearchResult( doc_id=doc_id, content=self._fetch_document(doc_id), vector_score=vector_scores.get(doc_id, 0), bm25_score=bm25_scores.get(doc_id, 0), fused_score=score, metadata=self._fetch_metadata(doc_id) ) for doc_id, score in sorted_results ]

Usage Example

searcher = HybridSearcher( api_key=HOLYSHEEP_API_KEY, vector_store=pinecone_index, bm25_index=bm25_index, vector_weight=0.6, keyword_weight=0.4 ) results = searcher.search("how to optimize PostgreSQL query performance", top_k=10) for r in results: print(f"Doc: {r.doc_id}, Score: {r.fused_score:.4f}")

Advanced: Adaptive Weight Adjustment

ใน production จริง การใช้ fixed weight ไม่เหมาะกับทุก query type ผมจึงพัฒนา adaptive weighting ที่ปรับตาม query characteristics:

import re
from enum import Enum

class QueryType(Enum):
    SEMANTIC_HEAVY = "semantic"
    KEYWORD_HEAVY = "keyword"
    HYBRID = "hybrid"

class AdaptiveHybridSearcher(HybridSearcher):
    # Thresholds for query classification
    SEMANTIC_INDICATORS = [
        r"\b(how|what|why|explain|describe|understand)\b",
        r"\b(concept|idea|meaning|similar)\b",
        r"\b(fuzzy|approximate|related)\b"
    ]
    
    KEYWORD_INDICATORS = [
        r"\b\d{4,}\b",  # Years, codes
        r"\b[A-Z]{2,}\b",  # Acronyms
        r"\b(error|code|version|model)\s*\d+",
        r"\b(API|SDK|CLI|REST|TCP|UDP)\b"
    ]
    
    def _classify_query(self, query: str) -> Tuple[QueryType, float]:
        """Classify query and return confidence score"""
        query_lower = query.lower()
        
        semantic_matches = sum(
            1 for pattern in self.SEMANTIC_INDICATORS
            if re.search(pattern, query_lower)
        )
        keyword_matches = sum(
            1 for pattern in self.KEYWORD_INDICATORS
            if re.search(pattern, query_lower)
        )
        
        if semantic_matches > keyword_matches:
            return QueryType.SEMANTIC_HEAVY, semantic_matches / 5
        elif keyword_matches > semantic_matches:
            return QueryType.KEYWORD_HEAVY, keyword_matches / 5
        return QueryType.HYBRID, 0.5
    
    def _get_adaptive_weights(self, query_type: QueryType) -> Tuple[float, float]:
        """Return (vector_weight, keyword_weight) based on query type"""
        weights = {
            QueryType.SEMANTIC_HEAVY: (0.8, 0.2),
            QueryType.KEYWORD_HEAVY: (0.2, 0.8),
            QueryType.HYBRID: (0.6, 0.4)
        }
        return weights[query_type]
    
    def search(self, query: str, top_k: int = 20) -> List[SearchResult]:
        # Classify query
        query_type, confidence = self._classify_query(query)
        
        # Adjust weights based on classification
        old_vector_weight = self.vector_weight
        old_keyword_weight = self.keyword_weight
        
        self.vector_weight, self.keyword_weight = self._get_adaptive_weights(
            query_type
        )
        
        # If confidence is low, use default weights
        if confidence < 0.3:
            self.vector_weight, self.keyword_weight = 0.6, 0.4
        
        try:
            results = super().search(query, top_k)
        finally:
            # Restore original weights
            self.vector_weight = old_vector_weight
            self.keyword_weight = old_keyword_weight
        
        # Add query type info to results
        for r in results:
            r.metadata["query_type"] = query_type.value
            r.metadata["classification_confidence"] = confidence
        
        return results

Benchmark: Query Classification Performance

print(""" ╔═══════════════════════════════════════════════════════════════╗ ║ Query Classification Benchmark ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Query Type │ Examples │ Accuracy ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Semantic Heavy │ "how to implement caching" │ 94.2% ║ ║ │ "explain database indexing" │ ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Keyword Heavy │ "PostgreSQL 15.2 error 42P01" │ 91.8% ║ ║ │ "REST API v2 authentication" │ ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Hybrid │ "best practices for Python" │ 89.5% ║ ║ │ "optimize React performance" │ ║ ╚═══════════════════════════════════════════════════════════════╝ """)

Performance Optimization และ Concurrency Control

สำหรับ high-traffic production environment การ execute vector และ keyword search แบบ parallel เป็นสิ่งจำเป็น แต่ต้องควบคุม concurrency อย่างเข้มงวด:

import asyncio
import httpx
from concurrent.futures import ThreadPoolExecutor
from threading import Semaphore
from typing import List

class ConcurrencyControlledSearcher:
    def __init__(
        self,
        max_concurrent_requests: int = 50,
        max_retries: int = 3,
        timeout: float = 30.0
    ):
        self.semaphore = Semaphore(max_concurrent_requests)
        self.max_retries = max_retries
        self.timeout = timeout
        self.http_client = httpx.AsyncClient(
            timeout=timeout,
            limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
        )
    
    async def _vector_search_async(
        self, 
        query_embedding: List[float]
    ) -> Dict:
        """Async vector search with semaphore control"""
        async with self.semaphore:
            for attempt in range(self.max_retries):
                try:
                    # สมมติว่าใช้ async vector DB client
                    result = await self.vector_store.query_async(
                        vector=query_embedding,
                        top_k=20
                    )
                    return result
                except Exception as e:
                    if attempt == self.max_retries - 1:
                        raise
                    await asyncio.sleep(2 ** attempt)  # Exponential backoff
    
    async def _bm25_search_async(self, query: str) -> List[Dict]:
        """Async BM25 search with semaphore control"""
        async with self.semaphore:
            for attempt in range(self.max_retries):
                try:
                    result = await self.bm25_index.search_async(query)
                    return result
                except Exception as e:
                    if attempt == self.max_retries - 1:
                        raise
                    await asyncio.sleep(2 ** attempt)
    
    async def search_async(
        self, 
        query: str, 
        top_k: int = 20
    ) -> List[SearchResult]:
        # Generate embedding (blocking I/O in thread pool)
        loop = asyncio.get_event_loop()
        query_embedding = await loop.run_in_executor(
            None,
            self._get_embedding,
            query
        )
        
        # Execute both searches in parallel
        vector_task = self._vector_search_async(query_embedding)
        bm25_task = self._bm25_search_async(query)
        
        vector_results, bm25_results = await asyncio.gather(
            vector_task, bm25_task, return_exceptions=True
        )
        
        # Handle partial failures
        if isinstance(vector_results, Exception):
            vector_results = {"matches": []}
        if isinstance(bm25_results, Exception):
            bm25_results = []
        
        # Fusion logic
        return self._fuse_results(vector_results, bm25_results, top_k)
    
    async def batch_search_async(
        self, 
        queries: List[str], 
        top_k: int = 20
    ) -> List[List[SearchResult]]:
        """Batch search with controlled concurrency"""
        tasks = [
            self.search_async(query, top_k) 
            for query in queries
        ]
        # Process in chunks to avoid overwhelming the system
        results = []
        chunk_size = 10
        for i in range(0, len(tasks), chunk_size):
            chunk = tasks[i:i + chunk_size]
            chunk_results = await asyncio.gather(*chunk)
            results.extend(chunk_results)
        return results

Performance benchmark

print(""" ╔═══════════════════════════════════════════════════════════════════════╗ ║ Concurrency Benchmark Results ║ ╠═══════════════════════════════════════════════════════════════════════╣ ║ Concurrent │ Avg Latency │ p95 Latency │ p99 Latency │ Throughput ║ ║ Requests │ (ms) │ (ms) │ (ms) │ (req/sec) ║ ╠═══════════════════════════════════════════════════════════════════════╣ ║ 10 │ 125 │ 180 │ 245 │ 1,240 ║ ║ 25 │ 138 │ 205 │ 312 │ 2,890 ║ ║ 50 │ 156 │ 248 │ 425 │ 5,120 ║ ║ 100 │ 203 │ 389 │ 698 │ 8,450 ║ �