ในโลกของ AI-powered search นั้น ไม่มีวิธีใดที่สมบูรณ์แบบสำหรับทุก use case การค้นหาด้วย Vector อย่างเดียวมักพลาดความหมายเชิงความถี่ของคำ (term frequency) ในขณะที่ Keyword Search อย่างเดียวไม่สามารถจับ semantic similarity ได้ Hybrid Search จึงเป็นคำตอบที่เหมาะสมที่สุดสำหรับ production system ที่ต้องการทั้งความแม่นยำและความเข้าใจเชิงความหมาย
ทำไมต้อง Hybrid Search?
จากประสบการณ์ในการสร้าง RAG system หลายตัว พบว่า pure vector search นั้นมีข้อจำกัดที่สำคัญ โดยเฉพาะกับข้อมูลที่มี:
- คำเฉพาะทาง (Technical Terms) — "REST API" กับ "RESTful Interface" อาจมี embedding ใกล้เคียงกันน้อยกว่าที่ควร
- ตัวเลขและสถิติ — การค้นหา "2024 Q3 revenue $5.2M" ต้องการ exact match ไม่ใช่ semantic similarity
- Brand Name หรือ Product Code — "iPhone 15 Pro Max" ไม่ควร match กับ "Samsung Galaxy S24 Ultra"
- Negation Queries — "ไม่มี error 500" ต้องการ keyword matching ไม่ใช่ vector similarity
สถาปัตยกรรม 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 ║
�