[MCP&A2A] 09. 하이브리드 검색 구현
[MCP&A2A] 09. 하이브리드 검색 구현
하이브리드 검색이란?
하이브리드 검색은 BM25 키워드 검색과 벡터 유사도 검색을 결합하여, 각각의 장점을 활용하고 단점을 보완하는 검색 방식입니다.
문제: 단일 검색 방식의 한계
벡터 검색만 사용할 때
실제 사례 - 금융 규제 문서 검색:
1
2
3
4
5
6
7
8
9
10
11
쿼리: "SEC Rule 10b-5 insider trading disclosure requirements"
벡터 검색 결과 (top 3):
1. "Corporate Governance Best Practices" (0.87) ❌
2. "Financial Reporting Guidelines" (0.84) ❌
3. "SEC Rule 10b-5 Insider Trading Regulations" (0.79) ✅
문제점:
- "SEC Rule 10b-5"라는 정확한 용어가 있는데도 3위
- 의미적으로 비슷하지만 관련 없는 문서가 상위
- 법률/규제 문서에서 치명적
왜 벡터 검색이 실패하는가?
- 임베딩은 의미의 “평균”을 학습
- 특정 전문 용어(SEC Rule 10b-5)의 중요성을 놓침
- 일반적인 개념(governance, reporting)에 가중치 부여
BM25만 사용할 때
실제 사례 - 기술 문서 검색:
1
2
3
4
5
6
7
8
9
10
11
쿼리: "How to fix memory leak in production"
BM25 검색 결과 (top 3):
1. "Memory Allocation Best Practices" (15.2) ⚠️
2. "Debugging Memory Leaks in Development" (14.8) ⚠️
3. "Production Deployment Checklist" (12.1) ⚠️
문제점:
- "production"과 "memory leak"가 각각 다른 문서에
- 동의어 처리 불가 (leak vs issue, fix vs resolve)
- 정확한 매칭만 찾음
왜 BM25가 실패하는가?
- 단어 기반 매칭만 수행
- 동의어, 유의어 처리 불가
- 문맥 이해 없음
해결: 하이브리드 검색
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
하이브리드 검색 = BM25 (정확도) + Vector (의미)
쿼리: "SEC Rule 10b-5 insider trading"
BM25 결과: Vector 결과:
1. SEC Rule 10b-5 (50.2) 1. Corporate Governance (0.87)
2. Insider Trading (45.1) 2. SEC Rule 10b-5 (0.79)
3. Trading Rules (30.5) 3. Financial Reporting (0.74)
↓ RRF (Reciprocal Rank Fusion) ↓
하이브리드 결과:
1. SEC Rule 10b-5 (0.0323) ✅ 정확한 문서
2. Insider Trading (0.0244)
3. Corporate Governance (0.0227)
BM25 알고리즘 이해
BM25 공식
1
2
3
4
5
6
7
8
9
10
11
12
BM25(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D| / avgdl))
where:
- D: 문서
- Q: 쿼리
- qi: 쿼리의 i번째 단어
- f(qi, D): 문서 D에서 qi의 빈도
- |D|: 문서 D의 길이
- avgdl: 평균 문서 길이
- k1: 용어 빈도 포화 파라미터 (일반적으로 1.2-2.0)
- b: 문서 길이 정규화 파라미터 (일반적으로 0.75)
- IDF(qi): 역문서 빈도
IDF (Inverse Document Frequency)
1
2
3
4
5
IDF(qi) = log((N - n(qi) + 0.5) / (n(qi) + 0.5))
where:
- N: 전체 문서 수
- n(qi): qi를 포함하는 문서 수
IDF의 의미:
- 흔한 단어(the, is, a) → IDF 낮음 (0에 가까움)
- 희귀한 단어(SEC, 10b-5) → IDF 높음
PostgreSQL에서 BM25
PostgreSQL의 ts_rank_cd는 BM25와 유사한 알고리즘을 사용:
1
2
3
4
5
6
7
8
9
10
SELECT
id,
title,
ts_rank_cd(
to_tsvector('english', content),
plainto_tsquery('english', 'SEC Rule 10b-5')
) AS bm25_score
FROM documents
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', 'SEC Rule 10b-5')
ORDER BY bm25_score DESC;
주요 함수:
to_tsvector: 문서를 검색 가능한 토큰으로 변환plainto_tsquery: 쿼리를 검색용으로 변환ts_rank_cd: BM25 유사 순위 계산@@: 매칭 연산자
벡터 검색 이해
코사인 유사도
1
2
3
4
5
6
7
8
similarity = cos(θ) = (A · B) / (||A|| × ||B||)
where:
- A, B: 임베딩 벡터
- A · B: 내적 (dot product)
- ||A||: 벡터 A의 크기
범위: -1 (반대) ~ 1 (동일)
pgvector 거리 연산자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 코사인 거리 (<=> 연산자)
-- 거리 = 1 - similarity
-- 범위: 0 (동일) ~ 2 (반대)
SELECT
id,
title,
embedding <=> '[0.1, 0.2, ..., 0.5]'::vector AS distance,
1 - (embedding <=> '[0.1, 0.2, ..., 0.5]'::vector) AS similarity
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ..., 0.5]'::vector
LIMIT 10;
-- 유클리드 거리 (<-> 연산자)
SELECT embedding <-> '[...]'::vector FROM documents;
-- 내적 (<#> 연산자, 음수 = 더 유사)
SELECT embedding <#> '[...]'::vector FROM documents;
권장: 코사인 거리 (<=>) - 임베딩 크기에 영향받지 않음
Reciprocal Rank Fusion (RRF)
RRF가 필요한 이유
문제: 점수 범위가 다름
1
2
3
4
5
6
7
8
9
BM25 점수: 0 ~ 50+ (문서마다 큰 편차)
Vector 점수: 0 ~ 1 (정규화됨)
단순 가중 평균의 문제:
score = 0.5 × bm25 + 0.5 × vector
= 0.5 × 45.2 + 0.5 × 0.85
= 22.6 + 0.425 = 23.025
→ BM25가 압도적으로 지배함!
RRF 공식
1
2
3
4
5
RRF_score = Σ 1 / (k + rank_i)
where:
- k: 상수 (일반적으로 60)
- rank_i: 각 검색 방법에서의 순위 (1, 2, 3, ...)
예제 계산:
1
2
3
4
5
6
7
8
9
10
11
문서 A:
- BM25 순위: 1위
- Vector 순위: 3위
- RRF = 1/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323
문서 B:
- BM25 순위: 2위
- Vector 순위: 1위
- RRF = 1/(60+2) + 1/(60+1) = 0.0161 + 0.0164 = 0.0325
→ 문서 B가 1위 (양쪽에서 고르게 높은 순위)
RRF의 장점:
- ✅ 점수 범위 차이 무시
- ✅ 순위만 사용하므로 공정
- ✅ Elasticsearch, Vespa 등에서 검증됨
- ✅ k=60이 경험적으로 최적
PostgreSQL 하이브리드 검색 구현
기본 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
WITH bm25_results AS (
-- BM25 검색 및 순위 매기기
...
),
vector_results AS (
-- Vector 검색 및 순위 매기기
...
),
combined AS (
-- RRF로 결합
...
)
SELECT * FROM combined;
완전한 SQL 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
-- 하이브리드 검색 함수
CREATE OR REPLACE FUNCTION hybrid_search(
p_tenant_id UUID,
p_query TEXT,
p_query_embedding vector(1536),
p_limit INTEGER DEFAULT 10,
p_bm25_weight FLOAT DEFAULT 0.5,
p_vector_weight FLOAT DEFAULT 0.5
)
RETURNS TABLE (
id UUID,
title TEXT,
content TEXT,
source TEXT,
combined_score FLOAT,
bm25_score FLOAT,
bm25_rank INTEGER,
vector_score FLOAT,
vector_rank INTEGER,
created_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
WITH bm25_results AS (
SELECT
d.id,
-- BM25 점수 계산
ts_rank_cd(
to_tsvector('english', d.content),
plainto_tsquery('english', p_query),
32 -- normalization = 32 (length normalization)
) AS bm25_score,
-- 순위 매기기 (1, 2, 3, ...)
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(
to_tsvector('english', d.content),
plainto_tsquery('english', p_query),
32
) DESC
) AS bm25_rank
FROM documents d
WHERE d.tenant_id = p_tenant_id
AND to_tsvector('english', d.content) @@ plainto_tsquery('english', p_query)
),
vector_results AS (
SELECT
d.id,
-- 코사인 유사도 (거리를 유사도로 변환)
1 - (d.embedding <=> p_query_embedding) AS vector_score,
-- 순위 매기기
ROW_NUMBER() OVER (
ORDER BY d.embedding <=> p_query_embedding
) AS vector_rank
FROM documents d
WHERE d.tenant_id = p_tenant_id
AND d.embedding IS NOT NULL -- NULL 임베딩 제외
),
combined AS (
SELECT
COALESCE(b.id, v.id) AS id,
-- RRF 점수 계산
(
COALESCE(1.0 / (60 + b.bm25_rank), 0) * p_bm25_weight +
COALESCE(1.0 / (60 + v.vector_rank), 0) * p_vector_weight
) AS combined_score,
COALESCE(b.bm25_score, 0) AS bm25_score,
b.bm25_rank,
COALESCE(v.vector_score, 0) AS vector_score,
v.vector_rank
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT
d.id,
d.title,
d.content,
d.source,
c.combined_score,
c.bm25_score,
c.bm25_rank,
c.vector_score,
c.vector_rank,
d.created_at
FROM combined c
JOIN documents d ON d.id = c.id
ORDER BY c.combined_score DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
FULL OUTER JOIN의 중요성
1
2
3
4
5
6
7
8
9
10
11
-- FULL OUTER JOIN 사용
-- BM25만 매칭: vector_rank = NULL
-- Vector만 매칭: bm25_rank = NULL
-- 둘 다 매칭: 두 순위 모두 있음
예제:
문서 A: BM25 1위, Vector 없음 → RRF = 1/(60+1) + 0
문서 B: BM25 없음, Vector 1위 → RRF = 0 + 1/(60+1)
문서 C: BM25 2위, Vector 3위 → RRF = 1/(60+2) + 1/(60+3)
→ FULL OUTER JOIN이 모든 문서를 포함
Go 구현
Database Layer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// internal/database/search.go
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/pgvector/pgvector-go"
)
type SearchParams struct {
TenantID string
Query string
Embedding []float32
Limit int
BM25Weight float64
VectorWeight float64
}
type SearchResult struct {
ID string
Title string
Content string
Source string
CombinedScore float64
BM25Score float64
BM25Rank *int // NULL 가능
VectorScore float64
VectorRank *int // NULL 가능
CreatedAt time.Time
}
func (db *DB) HybridSearch(ctx context.Context, params SearchParams) ([]SearchResult, error) {
// 트랜잭션 시작
tx, err := db.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// RLS 컨텍스트 설정
if err := db.SetTenantContext(ctx, tx, params.TenantID); err != nil {
return nil, fmt.Errorf("failed to set tenant context: %w", err)
}
// 쿼리 실행
rows, err := tx.Query(ctx, `
SELECT
id, title, content, source,
combined_score, bm25_score, bm25_rank,
vector_score, vector_rank, created_at
FROM hybrid_search($1, $2, $3, $4, $5, $6)
`,
params.TenantID,
params.Query,
pgvector.NewVector(params.Embedding),
params.Limit,
params.BM25Weight,
params.VectorWeight,
)
if err != nil {
return nil, fmt.Errorf("failed to execute search: %w", err)
}
defer rows.Close()
// 결과 파싱
var results []SearchResult
for rows.Next() {
var r SearchResult
err := rows.Scan(
&r.ID,
&r.Title,
&r.Content,
&r.Source,
&r.CombinedScore,
&r.BM25Score,
&r.BM25Rank,
&r.VectorScore,
&r.VectorRank,
&r.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit: %w", err)
}
return results, nil
}
NULL 임베딩 처리
문제: 새로 추가된 문서는 임베딩이 없을 수 있음
1
2
3
4
5
6
7
8
9
10
11
// 잘못된 방식 - NULL 스캔 시 에러
var embedding pgvector.Vector
err := rows.Scan(&embedding) // ❌ cannot scan NULL into pgvector.Vector
// 올바른 방식 - 포인터 사용
var embedding *pgvector.Vector
err := rows.Scan(&embedding) // ✅ NULL 허용
if embedding != nil {
doc.Embedding = embedding.Slice()
}
NULL 임베딩의 장점:
- 즉시 검색 가능: 문서 추가 즉시 BM25로 검색 가능
- 비동기 처리: 임베딩 생성을 백그라운드에서
- Graceful degradation: 임베딩 없어도 서비스 가능
- 무중단 업데이트: 임베딩 모델 변경 시 점진적 업데이트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 문서 저장 (임베딩 없이)
doc := &Document{
TenantID: tenantID,
Title: "New Document",
Content: "Content here",
Embedding: nil, // NULL 허용
}
db.InsertDocument(ctx, doc)
// 나중에 비동기로 임베딩 추가
go func() {
embedding := generateEmbedding(doc.Content)
db.UpdateEmbedding(ctx, doc.ID, embedding)
}()
// BM25로는 즉시 검색 가능!
results := db.HybridSearch(ctx, SearchParams{
Query: "New Document",
// Vector weight = 0이면 BM25만 사용
})
임베딩 생성
Ollama로 로컬 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# embedding/generator.py
import ollama
import numpy as np
def generate_embedding(text: str, model: str = "nomic-embed-text") -> list[float]:
"""
Ollama를 사용하여 임베딩 생성
Args:
text: 임베딩할 텍스트
model: 사용할 모델 (nomic-embed-text: 768차원)
Returns:
임베딩 벡터 (list of floats)
"""
response = ollama.embeddings(
model=model,
prompt=text
)
return response["embedding"]
# 사용 예제
embedding = generate_embedding("Machine learning is awesome")
print(f"Embedding dimension: {len(embedding)}") # 768
OpenAI API 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from openai import OpenAI
client = OpenAI(api_key="sk-...")
def generate_embedding_openai(text: str, model: str = "text-embedding-ada-002") -> list[float]:
"""
OpenAI API로 임베딩 생성
Args:
text: 임베딩할 텍스트
model: text-embedding-ada-002 (1536차원)
Returns:
임베딩 벡터
"""
response = client.embeddings.create(
input=text,
model=model
)
return response.data[0].embedding
# 사용
embedding = generate_embedding_openai("AI is transforming industries")
print(f"Dimension: {len(embedding)}") # 1536
배치 임베딩 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# embedding/batch_generator.py
import psycopg2
from psycopg2.extras import execute_values
import ollama
def batch_generate_embeddings(
db_url: str,
tenant_id: str,
batch_size: int = 100,
model: str = "nomic-embed-text"
):
"""
임베딩이 없는 문서들에 대해 배치로 임베딩 생성
"""
conn = psycopg2.connect(db_url)
cursor = conn.cursor()
try:
# 임베딩이 없는 문서 조회
cursor.execute("""
SELECT id, content
FROM documents
WHERE tenant_id = %s
AND embedding IS NULL
LIMIT %s
""", (tenant_id, batch_size))
documents = cursor.fetchall()
if not documents:
print("No documents to process")
return
print(f"Processing {len(documents)} documents...")
# 임베딩 생성 및 업데이트
updates = []
for doc_id, content in documents:
try:
# 임베딩 생성
embedding = ollama.embeddings(
model=model,
prompt=content
)["embedding"]
updates.append((doc_id, embedding))
print(f"✅ Generated embedding for {doc_id}")
except Exception as e:
print(f"❌ Failed for {doc_id}: {e}")
continue
# 배치 업데이트
execute_values(
cursor,
"""
UPDATE documents
SET embedding = data.embedding
FROM (VALUES %s) AS data(id, embedding)
WHERE documents.id = data.id::uuid
""",
updates,
template="(%s, %s::vector)"
)
conn.commit()
print(f"✅ Updated {len(updates)} documents")
finally:
cursor.close()
conn.close()
# 실행
if __name__ == "__main__":
batch_generate_embeddings(
db_url="postgresql://user:pass@localhost/db",
tenant_id="tenant-uuid-123",
batch_size=100
)
가중치 튜닝
도메인별 최적 가중치
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 법률/규제 문서 - 정확한 용어 매칭 중요
legal_weights = {
"bm25_weight": 0.7,
"vector_weight": 0.3
}
# 기술 문서 - 균형
technical_weights = {
"bm25_weight": 0.5,
"vector_weight": 0.5
}
# 창작 콘텐츠 - 의미적 유사성 중요
creative_weights = {
"bm25_weight": 0.3,
"vector_weight": 0.7
}
# 고객 지원 - 의도 파악 중요
support_weights = {
"bm25_weight": 0.4,
"vector_weight": 0.6
}
A/B 테스팅으로 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# evaluation/ab_test.py
import random
from typing import List, Tuple
def evaluate_search_quality(
queries: List[str],
ground_truth: List[List[str]], # 각 쿼리의 정답 문서 ID
weights: Tuple[float, float]
) -> dict:
"""
검색 품질 평가
Metrics:
- Precision@K: 상위 K개 중 정답 비율
- Recall@K: 전체 정답 중 찾은 비율
- MRR: Mean Reciprocal Rank
- NDCG: Normalized Discounted Cumulative Gain
"""
bm25_weight, vector_weight = weights
precisions = []
recalls = []
mrrs = []
for query, truth_ids in zip(queries, ground_truth):
# 검색 실행
results = hybrid_search(
query=query,
bm25_weight=bm25_weight,
vector_weight=vector_weight,
limit=10
)
result_ids = [r.id for r in results]
# Precision@10
relevant_in_results = len(set(result_ids) & set(truth_ids))
precision = relevant_in_results / len(result_ids)
precisions.append(precision)
# Recall@10
recall = relevant_in_results / len(truth_ids)
recalls.append(recall)
# MRR (Mean Reciprocal Rank)
for i, result_id in enumerate(result_ids):
if result_id in truth_ids:
mrrs.append(1.0 / (i + 1))
break
else:
mrrs.append(0.0)
return {
"precision@10": sum(precisions) / len(precisions),
"recall@10": sum(recalls) / len(recalls),
"mrr": sum(mrrs) / len(mrrs),
"weights": weights
}
# 여러 가중치 조합 테스트
weight_combinations = [
(0.3, 0.7),
(0.4, 0.6),
(0.5, 0.5),
(0.6, 0.4),
(0.7, 0.3),
]
results = []
for weights in weight_combinations:
metrics = evaluate_search_quality(test_queries, ground_truth, weights)
results.append(metrics)
print(f"Weights {weights}: {metrics}")
# 최적 가중치 선택
best = max(results, key=lambda x: x["mrr"])
print(f"\n✅ Best weights: {best['weights']}")
print(f" MRR: {best['mrr']:.4f}")
print(f" Precision@10: {best['precision@10']:.4f}")
print(f" Recall@10: {best['recall@10']:.4f}")
성능 벤치마크
테스트 환경
- CPU: 8 cores
- RAM: 16GB
- DB: PostgreSQL 16 + pgvector
- 데이터: 100,000 문서
- 임베딩: 1536차원 (OpenAI ada-002)
결과
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
검색 방식별 성능:
1. BM25만:
- P50 latency: 15ms
- P95 latency: 35ms
- P99 latency: 60ms
- Throughput: ~200 queries/sec
2. Vector만:
- P50 latency: 45ms
- P95 latency: 85ms
- P99 latency: 150ms
- Throughput: ~80 queries/sec
3. 하이브리드 (BM25 + Vector):
- P50 latency: 65ms
- P95 latency: 115ms
- P99 latency: 180ms
- Throughput: ~60 queries/sec
결론:
- 하이브리드가 약간 느리지만 검색 품질 20-30% 향상
- 프로덕션 환경에서 충분히 사용 가능한 성능
검색 품질 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
테스트 쿼리 100개, 전문가 레이블링
Precision@10:
- BM25만: 0.65
- Vector만: 0.58
- 하이브리드: 0.78 ✅ +20% 향상
MRR (Mean Reciprocal Rank):
- BM25만: 0.71
- Vector만: 0.66
- 하이브리드: 0.83 ✅ +16% 향상
특히 향상된 쿼리 유형:
- 전문 용어 + 맥락 (예: "GDPR Article 33 data breach")
- 긴 자연어 쿼리 (예: "How to implement OAuth 2.0 in production")
- 다의어 포함 쿼리 (예: "bank account security")
최적화 기법
1. 인덱스 튜닝
1
2
3
4
5
6
7
8
9
10
11
12
-- IVFFlat 인덱스 lists 파라미터 조정
-- lists = sqrt(rows) 권장
-- 100K 문서 → lists = 316
-- 1M 문서 → lists = 1000
CREATE INDEX idx_documents_embedding ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 316);
-- probes 설정 (쿼리 시)
-- probes가 높을수록 정확하지만 느림
SET ivfflat.probes = 10; -- 기본값은 1
2. 쿼리 캐싱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# cache/redis_cache.py
import redis
import json
import hashlib
class SearchCache:
def __init__(self, redis_url: str, ttl: int = 300):
self.redis = redis.from_url(redis_url)
self.ttl = ttl # 5분
def get_cache_key(self, query: str, params: dict) -> str:
"""쿼리와 파라미터로 캐시 키 생성"""
data = {
"query": query,
**params
}
content = json.dumps(data, sort_keys=True)
return f"search:{hashlib.md5(content.encode()).hexdigest()}"
def get(self, query: str, params: dict):
"""캐시된 결과 조회"""
key = self.get_cache_key(query, params)
cached = self.redis.get(key)
if cached:
return json.loads(cached)
return None
def set(self, query: str, params: dict, results: list):
"""결과 캐싱"""
key = self.get_cache_key(query, params)
self.redis.setex(
key,
self.ttl,
json.dumps(results)
)
# 사용
cache = SearchCache("redis://localhost:6379")
def search_with_cache(query: str, **params):
# 캐시 확인
cached = cache.get(query, params)
if cached:
print("✅ Cache hit")
return cached
# DB 검색
results = db.hybrid_search(query=query, **params)
# 캐싱
cache.set(query, params, results)
return results
3. 연결 풀 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// internal/database/postgres.go
import "github.com/jackc/pgx/v5/pgxpool"
func NewDB(databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
// 연결 풀 설정
config.MaxConns = 20 // 최대 연결 수
config.MinConns = 5 // 최소 연결 수
config.MaxConnLifetime = time.Hour // 연결 최대 수명
config.MaxConnIdleTime = 30 * time.Minute // 유휴 연결 타임아웃
config.HealthCheckPeriod = 1 * time.Minute // 헬스 체크 주기
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, err
}
return &DB{pool: pool}, nil
}
핵심 요약
하이브리드 검색의 장점
✅ 정확도 향상: BM25 + Vector = 20-30% 품질 개선 ✅ 강건성: 한 방법이 실패해도 다른 방법으로 보완 ✅ 유연성: 도메인별 가중치 조정 가능 ✅ NULL 처리: 임베딩 없어도 BM25로 검색 가능
구현 체크리스트
✅ SQL 함수: RRF 기반 하이브리드 검색 ✅ NULL 처리: 포인터 타입으로 안전하게 ✅ 인덱스: IVFFlat + GIN 모두 필요 ✅ 가중치: 도메인별 최적화 ✅ 캐싱: Redis로 성능 개선 ✅ 모니터링: 지연시간, 품질 지표 추적
실무 권장사항
1
2
3
4
5
6
7
8
9
10
11
12
13
기본 설정:
- BM25 weight: 0.5
- Vector weight: 0.5
- RRF k: 60
- Limit: 10-20
법률/규제:
- BM25 weight: 0.7
- Vector weight: 0.3
창작/지원:
- BM25 weight: 0.3
- Vector weight: 0.7
참고 자료:
- BM25: Robertson & Zaragoza (2009)
- RRF: Cormack et al. (2009)
- pgvector: https://github.com/pgvector/pgvector
작성일: 2024-12-13
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.