AI Agents · 강의

검색 결과 순위 지정 및 필터링

관련성 점수 계산, 중복 제거, 컨텍스트에 가장 적합한 결과 선택을 다룹니다.

레슨 2/413개 단계

검색 결과 순위 지정 및 필터링은(는) CoddyKit의 무료 AI Agents 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 AI Agents 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. AI Agents 강의에는 총 4개의 강의가 포함되어 있습니다.

순위 지정과 필터링이 중요한 이유

검색 API는 5~10개의 결과를 반환하지만, 모든 결과가 에이전트의 작업에 똑같이 관련성 높고 신뢰할 수 있으며 유용한 것은 아닙니다. 원시 결과를 LLM에 바로 전달하면 맥락 토큰을 낭비하고 노이즈나 잘못된 정보가 유입될 수 있습니다.

순위 지정과 필터링은 결과가 LLM에 전달되기 전에 신호 대 잡음비를 개선합니다.

BM25를 사용한 관련성 점수 계산

BM25(최적 일치 25)는 질의와 키워드가 얼마나 겹치는지를 기준으로 문서에 점수를 매기는 고전적인 텍스트 순위 알고리즘입니다. 질의와 문서가 같은 단어를 공유하는 어휘 기반 일치에 효과적입니다.

pip install rank-bm25로 설치합니다.

from rank_bm25 import BM25Okapi

def rank_with_bm25(query, results):
    # Tokenize: lowercase and split into words
    tokenized_results = [
        r['content'].lower().split()
        for r in results
    ]
    bm25 = BM25Okapi(tokenized_results)

    query_tokens = query.lower().split()
    scores = bm25.get_scores(query_tokens)

    # Sort results by score descending
    ranked = sorted(
        zip(scores, results),
        key=lambda x: x[0],
        reverse=True
    )
    return [(score, result) for score, result in ranked]

임베딩을 사용한 관련성 점수 계산

BM25는 정확히 일치하는 단어만 찾습니다. 임베딩 유사도는 의미를 포착하므로, 단어가 서로 달라도 'Python 웹 개발'과 'Django로 웹 사이트 만들기'의 유사도 점수는 높게 나옵니다.

질의 임베딩과 결과 임베딩 사이의 코사인 유사도를 사용합니다.

import numpy as np
import openai
import os

client = openai.OpenAI(api_key=os.getenv('OPENAI_API_KEY'))

def embed(text):
    resp = client.embeddings.create(
        model='text-embedding-3-small',
        input=text[:8000]
    )
    return np.array(resp.data[0].embedding)

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

def rank_by_embedding(query, results):
    q_emb = embed(query)
    scored = []
    for r in results:
        r_emb = embed(r['content'][:1000])
        score = cosine_similarity(q_emb, r_emb)
        scored.append((score, r))
    return sorted(scored, key=lambda x: x[0], reverse=True)

하이브리드 순위 지정: BM25 + 임베딩

BM25 점수와 임베딩 점수는 관련성의 서로 다른 측면을 포착합니다. 하이브리드 순위 지정은 가중 평균으로 두 점수를 결합하여 어휘 기반 일치와 의미 기반 일치의 장점을 모두 얻습니다.

def hybrid_rank(query, results, bm25_weight=0.4, embed_weight=0.6):
    # Get BM25 scores (normalized 0-1)
    bm25_scored = rank_with_bm25(query, results)
    max_bm25 = max(s for s, _ in bm25_scored) or 1
    bm25_norm = {r['url']: s / max_bm25 for s, r in bm25_scored}

    # Get embedding scores
    embed_scored = rank_by_embedding(query, results)
    embed_norm = {r['url']: s for s, r in embed_scored}

    # Combine
    combined = []
    for r in results:
        url = r['url']
        score = (bm25_weight * bm25_norm.get(url, 0) +
                 embed_weight * embed_norm.get(url, 0))
        combined.append((score, r))

    return sorted(combined, key=lambda x: x[0], reverse=True)

URL을 기준으로 중복 제거

검색 결과에는 유사한 중복 항목이 자주 포함됩니다. 여러 배포 출처에 실린 동일한 기사이거나, URL 매개변수만 다른 동일한 페이지일 수 있습니다. LLM에 결과를 전달하기 전에 이러한 중복을 제거합니다.

from urllib.parse import urlparse, urlunparse

def normalize_url(url):
    parsed = urlparse(url)
    # Remove query params and fragment (tracking params, etc.)
    clean = parsed._replace(query='', fragment='')
    return urlunparse(clean).rstrip('/')

def deduplicate_results(results):
    seen_urls = set()
    unique = []
    for r in results:
        url = normalize_url(r.get('url', ''))
        if url not in seen_urls:
            seen_urls.add(url)
            unique.append(r)
    return unique

# Also deduplicate by content similarity (near-duplicate detection)
def deduplicate_by_content(results, min_unique_ratio=0.7):
    unique = [results[0]] if results else []
    for candidate in results[1:]:
        cand_words = set(candidate['content'].lower().split())
        is_duplicate = False
        for kept in unique:
            kept_words = set(kept['content'].lower().split())
            overlap = len(cand_words & kept_words) / max(len(cand_words), 1)
            if overlap > (1 - min_unique_ratio):
                is_duplicate = True
                break
        if not is_duplicate:
            unique.append(candidate)
    return unique

if __name__ == '__main__':
    demo_results = [
        {'url': 'https://example.com/a?utm_source=x'},
        {'url': 'https://example.com/a'},
        {'url': 'https://example.com/b'},
    ]
    unique = deduplicate_results(demo_results)
    print(f'{len(demo_results)} results -> {len(unique)} unique')
    for r in unique:
        print(' -', r['url'])

도메인 품질 점수 계산

docs.python.org에서 가져온 결과는 임의의 블로그에서 가져온 결과보다 신뢰할 수 있습니다. 도메인을 품질 등급별로 나누어 가중치를 할당하고 최종 순위에 반영합니다.

DOMAIN_QUALITY = {
    # Tier 1 — authoritative (1.3x boost)
    'docs.python.org': 1.3,
    'developer.mozilla.org': 1.3,
    'arxiv.org': 1.3,
    'github.com': 1.2,
    'stackoverflow.com': 1.2,
    # Tier 2 — good (1.0x, no change)
    # Tier 3 — low quality (penalty)
    'pinterest.com': 0.3,
    'quora.com': 0.5,
    'wikihow.com': 0.6
}

def get_domain_multiplier(url):
    from urllib.parse import urlparse
    domain = urlparse(url).netloc.lower().replace('www.', '')
    return DOMAIN_QUALITY.get(domain, 1.0)  # default: no change

def apply_domain_boost(scored_results):
    boosted = []
    for score, r in scored_results:
        multiplier = get_domain_multiplier(r.get('url', ''))
        boosted.append((score * multiplier, r))
    return sorted(boosted, key=lambda x: x[0], reverse=True)

if __name__ == '__main__':
    scored = [(1.0, {'url': 'https://pinterest.com/x'}), (1.0, {'url': 'https://docs.python.org/x'})]
    for score, r in apply_domain_boost(scored):
        print(f"{r['url']}: boosted score {score:.2f}")

품질이 낮은 결과 필터링

도메인과 관계없이 구조적으로 품질이 낮은 결과도 있습니다. 너무 짧아 유용하지 않거나, 대부분이 탐색 메뉴 텍스트이거나, 로그인이 필요한 페이지에서 가져온 결과가 이에 해당합니다. 순위를 매기기 전에 이러한 결과를 걸러냅니다.

MIN_CONTENT_LENGTH = 200  # characters

LOW_QUALITY_SIGNALS = [
    'sign in to view',
    'please log in',
    'subscribe to read',
    '404 not found',
    'access denied',
    'this content is for members only'
]

def is_quality_result(result):
    content = result.get('content', '')

    # Too short
    if len(content) < MIN_CONTENT_LENGTH:
        return False

    # Paywall / access barrier detected
    content_lower = content.lower()
    for signal in LOW_QUALITY_SIGNALS:
        if signal in content_lower:
            return False

    return True

def filter_results(results):
    return [r for r in results if is_quality_result(r)]

if __name__ == '__main__':
    demo_results = [
        {'content': 'Please log in to view this article which has plenty of extra padding text here.'},
        {'content': 'A' * 250},
    ]
    kept = filter_results(demo_results)
    print(f'{len(demo_results)} results -> {len(kept)} passed quality filter')

문맥 예산에 맞춘 결과 자르기

필터링한 후에도 결과 5개가 각각 600자라면 전체 분량은 3,000자입니다. LLM의 문맥 예산에 맞는 결과 개수를 정하고 그에 맞게 내용을 잘라냅니다.

MAX_CONTEXT_CHARS = 4000
MAX_SNIPPET_CHARS = 600

def truncate_for_context(ranked_results, budget=MAX_CONTEXT_CHARS):
    selected = []
    used_chars = 0

    for score, result in ranked_results:
        content = result.get('content', '')[:MAX_SNIPPET_CHARS]
        entry = f"Source: {result['title']}\nURL: {result['url']}\nContent: {content}"
        entry_len = len(entry)

        if used_chars + entry_len > budget:
            break

        selected.append(result)
        used_chars += entry_len

    return selected

ranked_results = [
    (0.9, {'title': 'Doc A', 'url': 'http://a', 'content': 'x' * 800}),
    (0.7, {'title': 'Doc B', 'url': 'http://b', 'content': 'y' * 800}),
]
budget = 1000
selected = truncate_for_context(ranked_results, budget=budget)
print(f'Selected {len(selected)} results within {budget}-char budget')

LLM 프롬프트용 결과 형식 지정

순위를 매기고 중복을 제거한 다음 내용을 자른 후, LLM 프롬프트에서 결과를 번호가 매겨진 목록으로 형식화합니다. 번호가 있는 출처를 사용하면 모델이 답변에서 출처를 쉽게 인용할 수 있습니다.

def format_results_for_prompt(results):
    lines = ['Here are relevant search results:\n']
    for i, r in enumerate(results, 1):
        lines.append(f'[{i}] {r["title"]}')
        lines.append(f'    URL: {r["url"]}')
        lines.append(f'    {r.get("content", "")[:400]}')
        lines.append('')
    lines.append('Use these sources to answer the question. Cite sources as [1], [2], etc.')
    return '\n'.join(lines)

# Usage in agent prompt
formatted = format_results_for_prompt(selected_results)
response = llm_call(
    system='You are a research assistant.',
    user=f'{formatted}\n\nQuestion: {user_question}'
)

검색 결과 캐싱

동일한 질의가 여러 세션이나 에이전트 반복 과정에서 다시 사용될 수 있습니다. 검색 결과를 짧은 TTL(예: 1시간) 동안 캐시하면 API 비용을 줄이고 반복 질의의 응답 시간을 개선할 수 있습니다.

import hashlib
import time

search_cache = {}  # In production: use Redis or disk cache
CACHE_TTL = 3600  # 1 hour

def cached_search(query, **kwargs):
    cache_key = hashlib.md5(query.encode()).hexdigest()
    entry = search_cache.get(cache_key)

    if entry and (time.time() - entry['ts']) < CACHE_TTL:
        print('Search cache hit')
        return entry['results']

    results = client.search(query=query, **kwargs)
    search_cache[cache_key] = {
        'results': results,
        'ts': time.time()
    }
    return results

전체 필터링 및 순위 지정 과정

모든 단계를 하나의 처리 함수로 연결합니다. 가져오기 → 품질이 낮은 결과 걸러내기 → 중복 제거 → 순위 지정 → 도메인 가중치 적용 → 문맥에 맞게 자르기 → 프롬프트용 형식 지정

def search_and_rank(query, max_context_chars=4000):
    # 1. Fetch
    raw = cached_search(query, max_results=8)
    results = raw.get('results', [])

    # 2. Filter quality
    results = filter_results(results)

    # 3. Deduplicate
    results = deduplicate_results(results)

    # 4. Rank (BM25 fast path — save embedding costs)
    scored = rank_with_bm25(query, results)

    # 5. Domain boost
    scored = apply_domain_boost(scored)

    # 6. Truncate to context budget
    selected = truncate_for_context(scored, budget=max_context_chars)

    # 7. Format
    return format_results_for_prompt(selected)

이해도 확인

BM25만 사용하는 것보다 하이브리드 순위 지정(BM25 + 임베딩)을 사용할 때 얻는 가장 큰 장점은 무엇인가요?

복습: 검색 결과 순위 지정 및 필터링

원시 검색 결과는 LLM에 전달되기 전에 처리해야 합니다. 처리 흐름은 다음과 같습니다. 품질이 낮은 결과 걸러내기(너무 짧거나 유료 접근이 필요한 결과) → URL과 내용 기준으로 중복 제거 → BM25 및 임베딩 유사도로 순위 지정 → 도메인 품질 가중치 적용 → 문맥 예산에 맞게 자르기 → 번호가 있는 출처로 형식 지정

검색 결과를 캐시하면 반복 질의에 대한 API 비용을 줄일 수 있습니다. 프롬프트에 번호가 있는 인용을 포함하면 LLM이 주장을 특정 출처와 연결할 수 있습니다.

무료로 시작

AI 튜터와 함께 AI Agents을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
60
레슨
239

자주 묻는 질문

“검색 결과 순위 지정 및 필터링” 강의는 무료인가요?

네 — “검색 결과 순위 지정 및 필터링” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 AI Agents 강의 전체를 잠금 해제할 수 있습니다. AI Agents 강의에는 총 4개의 강의가 포함되어 있습니다.

“검색 결과 순위 지정 및 필터링”에서 뭘 배우나요?

관련성 점수 계산, 중복 제거, 컨텍스트에 가장 적합한 결과 선택을 다룹니다. 브라우저에서 직접 실행하는 실습 코드로 AI Agents을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

AI Agents을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 AI Agents은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.

“검색 결과 순위 지정 및 필터링” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 AI Agents 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 AI Agents 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 에이전트 검색을 위한 Tavily 및 SerpAPI
  2. 검색 결과 순위 지정 및 필터링
  3. 심층 연구 반복 패턴
  4. 웹 검색과 RAG 결합
← AI Agents(으)로 돌아가기