Agentic Architecture Pattern: Search Agent

Written by

in


    """
Pattern 235: Search Agent

Purpose: Intelligent search agent with query processing, ranking, and retrieval strategies.
Implements semantic search, keyword search, and hybrid approaches with relevance scoring.
"""

from typing import Dict, Any, List, Optional, Set
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from collections import defaultdict
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
import re


class SearchType(Enum):
    """Types of search strategies."""
    KEYWORD = "keyword"  # Keyword matching
    SEMANTIC = "semantic"  # Semantic/meaning-based
    FUZZY = "fuzzy"  # Fuzzy matching
    BOOLEAN = "boolean"  # Boolean operators (AND, OR, NOT)
    HYBRID = "hybrid"  # Combination of strategies


class SearchScope(Enum):
    """Scope of search."""
    TITLE = "title"
    CONTENT = "content"
    TAGS = "tags"
    METADATA = "metadata"
    ALL = "all"


@dataclass
class Document:
    """Document to be searched."""
    id: str
    title: str
    content: str
    tags: List[str] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)
    created_at: datetime = field(default_factory=datetime.now)
    views: int = 0
    popularity: float = 0.0


@dataclass
class SearchQuery:
    """Processed search query."""
    original_query: str
    processed_terms: List[str]
    operators: Dict[str, List[str]] = field(default_factory=dict)  # AND, OR, NOT
    filters: Dict[str, Any] = field(default_factory=dict)
    scope: SearchScope = SearchScope.ALL


@dataclass
class SearchResult:
    """A single search result."""
    document: Document
    relevance_score: float
    matched_fields: List[str]
    highlights: List[str] = field(default_factory=list)
    rank: int = 0


class SearchAgent:
    """
    Search Agent - Intelligent search with multiple strategies.
    
    Features:
    - Query processing and understanding
    - Multiple search strategies (keyword, semantic, fuzzy, boolean)
    - Relevance scoring and ranking
    - Result highlighting
    - Search filters
    - Query expansion
    - Search analytics
    """
    
    def __init__(self,
                 name: str,
                 search_type: SearchType = SearchType.HYBRID):
        self.name = name
        self.search_type = search_type
        self.llm = ChatOpenAI(model="gpt-4", temperature=0.3)
        
        # Document store
        self.documents: Dict[str, Document] = {}
        self.inverted_index: Dict[str, Set[str]] = defaultdict(set)  # term -> doc_ids
        
        # Statistics
        self.statistics: Dict[str, Any] = {
            "total_searches": 0,
            "documents_indexed": 0,
            "avg_results_per_query": 0.0,
            "search_type_usage": defaultdict(int)
        }
    
    def add_document(self, document: Document):
        """Add document to search index."""
        self.documents[document.id] = document
        self._index_document(document)
        self.statistics["documents_indexed"] += 1
    
    def _index_document(self, document: Document):
        """Build inverted index for document."""
        # Index title
        for term in self._tokenize(document.title):
            self.inverted_index[term].add(document.id)
        
        # Index content
        for term in self._tokenize(document.content):
            self.inverted_index[term].add(document.id)
        
        # Index tags
        for tag in document.tags:
            self.inverted_index[tag.lower()].add(document.id)
    
    def search(self,
               query: str,
               max_results: int = 10,
               scope: SearchScope = SearchScope.ALL,
               filters: Optional[Dict[str, Any]] = None) -> List[SearchResult]:
        """Execute search query."""
        self.statistics["total_searches"] += 1
        self.statistics["search_type_usage"][self.search_type.value] += 1
        
        # Process query
        processed_query = self._process_query(query, scope, filters)
        
        # Execute search strategy
        if self.search_type == SearchType.KEYWORD:
            results = self._keyword_search(processed_query)
        elif self.search_type == SearchType.SEMANTIC:
            results = self._semantic_search(processed_query)
        elif self.search_type == SearchType.FUZZY:
            results = self._fuzzy_search(processed_query)
        elif self.search_type == SearchType.BOOLEAN:
            results = self._boolean_search(processed_query)
        else:  # HYBRID
            results = self._hybrid_search(processed_query)
        
        # Apply filters
        if filters:
            results = self._apply_filters(results, filters)
        
        # Rank results
        results = self._rank_results(results, processed_query)
        
        # Limit results
        results = results[:max_results]
        
        # Add highlights
        for result in results:
            result.highlights = self._generate_highlights(result.document, processed_query)
        
        # Update statistics
        if results:
            avg = self.statistics["avg_results_per_query"]
            total = self.statistics["total_searches"]
            self.statistics["avg_results_per_query"] = (avg * (total - 1) + len(results)) / total
        
        return results
    
    def _process_query(self,
                      query: str,
                      scope: SearchScope,
                      filters: Optional[Dict[str, Any]]) -> SearchQuery:
        """Process and understand query."""
        # Extract boolean operators
        operators = {
            "AND": [],
            "OR": [],
            "NOT": []
        }
        
        # Simple boolean parsing
        if " AND " in query.upper():
            operators["AND"] = re.split(r'\s+AND\s+', query, flags=re.IGNORECASE)
            query = " ".join(operators["AND"])
        elif " OR " in query.upper():
            operators["OR"] = re.split(r'\s+OR\s+', query, flags=re.IGNORECASE)
        elif " NOT " in query.upper():
            parts = re.split(r'\s+NOT\s+', query, flags=re.IGNORECASE)
            query = parts[0]
            operators["NOT"] = parts[1:] if len(parts) > 1 else []
        
        # Tokenize
        processed_terms = self._tokenize(query)
        
        return SearchQuery(
            original_query=query,
            processed_terms=processed_terms,
            operators=operators,
            filters=filters or {},
            scope=scope
        )
    
    def _tokenize(self, text: str) -> List[str]:
        """Tokenize text into terms."""
        # Simple tokenization (in production, use proper NLP)
        text = text.lower()
        text = re.sub(r'[^\w\s]', ' ', text)
        return [term for term in text.split() if len(term) > 2]
    
    def _keyword_search(self, query: SearchQuery) -> List[SearchResult]:
        """Keyword-based search."""
        doc_scores: Dict[str, float] = defaultdict(float)
        doc_matches: Dict[str, Set[str]] = defaultdict(set)
        
        # Find matching documents
        for term in query.processed_terms:
            if term in self.inverted_index:
                for doc_id in self.inverted_index[term]:
                    doc_scores[doc_id] += 1.0
                    doc_matches[doc_id].add(term)
        
        # Create results
        results = []
        for doc_id, score in doc_scores.items():
            document = self.documents[doc_id]
            
            # TF-IDF-like scoring
            term_frequency = score / len(query.processed_terms)
            doc_frequency = len(doc_matches[doc_id]) / len(self.inverted_index.get(list(doc_matches[doc_id])[0], {doc_id}))
            relevance_score = term_frequency * doc_frequency
            
            results.append(SearchResult(
                document=document,
                relevance_score=relevance_score,
                matched_fields=list(doc_matches[doc_id])
            ))
        
        return results
    
    def _semantic_search(self, query: SearchQuery) -> List[SearchResult]:
        """Semantic/meaning-based search using LLM."""
        # Use LLM to understand query intent and find relevant documents
        doc_summaries = "\n".join([
            f"{doc_id}: {doc.title} - {doc.content[:100]}"
            for doc_id, doc in list(self.documents.items())[:50]
        ])
        
        prompt = ChatPromptTemplate.from_messages([
            ("system", "You are a search expert. Analyze the query and rank documents by relevance. "
                      "Return document IDs in order of relevance, separated by commas."),
            ("human", f"Query: {query.original_query}\n\nDocuments:\n{doc_summaries}\n\n"
                     f"Return top relevant document IDs:")
        ])
        
        chain = prompt | self.llm
        response = chain.invoke({})
        
        # Parse LLM response
        relevant_doc_ids = [doc_id.strip() for doc_id in str(response.content).split(",")[:20]]
        
        # Create results with semantic scores
        results = []
        for i, doc_id in enumerate(relevant_doc_ids):
            if doc_id in self.documents:
                results.append(SearchResult(
                    document=self.documents[doc_id],
                    relevance_score=1.0 - (i * 0.05),  # Decreasing score
                    matched_fields=["semantic"]
                ))
        
        return results
    
    def _fuzzy_search(self, query: SearchQuery) -> List[SearchResult]:
        """Fuzzy matching search."""
        results = []
        
        for doc_id, document in self.documents.items():
            # Calculate fuzzy match score
            score = 0.0
            matched_fields = []
            
            for term in query.processed_terms:
                # Title fuzzy match
                if self._fuzzy_match(term, document.title):
                    score += 2.0
                    matched_fields.append("title")
                
                # Content fuzzy match
                if self._fuzzy_match(term, document.content):
                    score += 1.0
                    matched_fields.append("content")
                
                # Tag match
                for tag in document.tags:
                    if self._fuzzy_match(term, tag):
                        score += 1.5
                        matched_fields.append("tags")
            
            if score > 0:
                results.append(SearchResult(
                    document=document,
                    relevance_score=score / len(query.processed_terms),
                    matched_fields=list(set(matched_fields))
                ))
        
        return results
    
    def _fuzzy_match(self, term: str, text: str) -> bool:
        """Simple fuzzy matching."""
        term = term.lower()
        text = text.lower()
        
        # Exact match
        if term in text:
            return True
        
        # Partial match (at least 70% characters)
        match_threshold = int(len(term) * 0.7)
        matched_chars = sum(1 for c in term if c in text)
        return matched_chars >= match_threshold
    
    def _boolean_search(self, query: SearchQuery) -> List[SearchResult]:
        """Boolean operator search (AND, OR, NOT)."""
        if query.operators["AND"]:
            # All terms must match
            doc_ids: Optional[Set[str]] = None
            for term in query.operators["AND"]:
                term_docs = set()
                for t in self._tokenize(term):
                    term_docs.update(self.inverted_index.get(t, set()))
                
                doc_ids = term_docs if doc_ids is None else doc_ids & term_docs
            
            doc_ids = doc_ids or set()
            
        elif query.operators["OR"]:
            # Any term can match
            doc_ids = set()
            for term in query.operators["OR"]:
                for t in self._tokenize(term):
                    doc_ids.update(self.inverted_index.get(t, set()))
        
        else:
            # Default keyword search
            return self._keyword_search(query)
        
        # Exclude NOT terms
        if query.operators["NOT"]:
            exclude_ids = set()
            for term in query.operators["NOT"]:
                for t in self._tokenize(term):
                    exclude_ids.update(self.inverted_index.get(t, set()))
            doc_ids -= exclude_ids
        
        # Create results
        results = []
        for doc_id in doc_ids:
            if doc_id in self.documents:
                results.append(SearchResult(
                    document=self.documents[doc_id],
                    relevance_score=1.0,
                    matched_fields=["boolean"]
                ))
        
        return results
    
    def _hybrid_search(self, query: SearchQuery) -> List[SearchResult]:
        """Hybrid search combining multiple strategies."""
        # Combine keyword and semantic search
        keyword_results = self._keyword_search(query)
        fuzzy_results = self._fuzzy_search(query)
        
        # Merge results with weighted scores
        combined: Dict[str, SearchResult] = {}
        
        # Weight: keyword 60%, fuzzy 40%
        for result in keyword_results:
            combined[result.document.id] = SearchResult(
                document=result.document,
                relevance_score=result.relevance_score * 0.6,
                matched_fields=result.matched_fields
            )
        
        for result in fuzzy_results:
            if result.document.id in combined:
                combined[result.document.id].relevance_score += result.relevance_score * 0.4
                combined[result.document.id].matched_fields.extend(result.matched_fields)
            else:
                combined[result.document.id] = SearchResult(
                    document=result.document,
                    relevance_score=result.relevance_score * 0.4,
                    matched_fields=result.matched_fields
                )
        
        return list(combined.values())
    
    def _apply_filters(self,
                       results: List[SearchResult],
                       filters: Dict[str, Any]) -> List[SearchResult]:
        """Apply filters to search results."""
        filtered = []
        
        for result in results:
            doc = result.document
            include = True
            
            # Apply each filter
            if "tags" in filters:
                if not any(tag in doc.tags for tag in filters["tags"]):
                    include = False
            
            if "min_views" in filters:
                if doc.views < filters["min_views"]:
                    include = False
            
            if "created_after" in filters:
                if doc.created_at  List[SearchResult]:
        """Rank search results."""
        # Sort by relevance score (descending)
        sorted_results = sorted(results, key=lambda r: r.relevance_score, reverse=True)
        
        # Assign ranks
        for i, result in enumerate(sorted_results, 1):
            result.rank = i
        
        return sorted_results
    
    def _generate_highlights(self,
                           document: Document,
                           query: SearchQuery) -> List[str]:
        """Generate highlighted snippets."""
        highlights = []
        content_lower = document.content.lower()
        
        for term in query.processed_terms[:3]:  # Top 3 terms
            # Find term in content
            idx = content_lower.find(term.lower())
            if idx != -1:
                # Extract context (50 chars before and after)
                start = max(0, idx - 50)
                end = min(len(document.content), idx + len(term) + 50)
                snippet = document.content[start:end]
                
                # Add ellipsis
                if start > 0:
                    snippet = "..." + snippet
                if end  Dict[str, Any]:
        """Get search statistics."""
        return {
            "name": self.name,
            "search_strategy": self.search_type.value,
            **self.statistics
        }


class SearchPattern:
    """
    Search Pattern - Manage search agents for different domains.
    
    Coordinates multiple search agents with different strategies.
    """
    
    def __init__(self):
        self.agents: Dict[str, SearchAgent] = {}
    
    def create_agent(self,
                     name: str,
                     search_type: SearchType = SearchType.HYBRID) -> SearchAgent:
        """Create a new search agent."""
        agent = SearchAgent(name, search_type)
        self.agents[name] = agent
        print(f"   Created search agent: {name} ({search_type.value})")
        return agent
    
    def run(self,
            agent_name: str,
            query: str,
            max_results: int = 5) -> Dict[str, Any]:
        """Execute search query."""
        if agent_name not in self.agents:
            return {"error": f"Agent {agent_name} not found"}
        
        agent = self.agents[agent_name]
        results = agent.search(query, max_results)
        
        print(f"\n=== Search Results from {agent_name} ===")
        print(f"Query: \"{query}\"")
        print(f"Found {len(results)} results\n")
        
        for result in results:
            print(f"#{result.rank}. {result.document.title} (Score: {result.relevance_score:.2f})")
            print(f"   Tags: {', '.join(result.document.tags)}")
            if result.highlights:
                print(f"   Preview: {result.highlights[0]}")
            print()
        
        return {
            "agent": agent_name,
            "query": query,
            "results_count": len(results),
            "statistics": agent.get_statistics()
        }


# Example usage
if __name__ == "__main__":
    print("=== Search Agent Pattern Demo ===\n")
    
    # Create pattern
    pattern = SearchPattern()
    
    # Create search agents
    print("1. Setting up search agents...")
    
    web_search = pattern.create_agent("WebSearch", SearchType.HYBRID)
    doc_search = pattern.create_agent("DocSearch", SearchType.KEYWORD)
    
    # Add sample documents
    print("\n2. Indexing documents...")
    
    documents = [
        Document("d1", "Introduction to Machine Learning",
                "Machine learning is a subset of artificial intelligence that focuses on data and algorithms.",
                ["AI", "ML", "tutorial"]),
        Document("d2", "Deep Learning Fundamentals",
                "Deep learning uses neural networks with multiple layers to learn from data.",
                ["AI", "deep-learning", "neural-networks"]),
        Document("d3", "Natural Language Processing Guide",
                "NLP enables computers to understand, interpret and generate human language.",
                ["NLP", "AI", "language"]),
        Document("d4", "Computer Vision Applications",
                "Computer vision allows machines to interpret and understand visual information.",
                ["CV", "AI", "image-processing"]),
        Document("d5", "Reinforcement Learning Basics",
                "Reinforcement learning trains agents through trial and error using rewards.",
                ["RL", "AI", "agents"]),
        Document("d6", "Data Science with Python",
                "Python is the leading language for data science and machine learning.",
                ["python", "data-science", "tutorial"]),
    ]
    
    for doc in documents:
        web_search.add_document(doc)
    
    # Execute searches
    print("\n3. Executing searches...")
    
    queries = [
        "machine learning neural networks",
        "python data science",
        "artificial intelligence applications"
    ]
    
    for query in queries:
        result = pattern.run("WebSearch", query, max_results=3)
    
    # Print statistics
    print("\n4. Search Statistics:")
    stats = result["statistics"]
    for key, value in stats.items():
        print(f"   {key}: {value}")
    
    print("\nāœ“ Intelligent search with ranking and relevance scoring complete!")


    

Agentic Architecture Pattern: Search Agent

Pattern 235: 235 Search Agent – Line-by-Line Explanation

Overview

Pattern 235: Search Agent

Purpose: Intelligent search agent with query processing, ranking, and retrieval strategies. Implements semantic search, keyword search, and hybrid approaches with relevance scoring.


Code Structure Analysis

File Information

  • Pattern Number: 235
  • Pattern Name: 235 Search Agent
  • File Path: patterns\domain_specific\235_search_agent.py
  • Total Lines: 581

Detailed Code Explanation

Module Docstring

"""
Pattern 235: Search Agent

Purpose: Intelligent search agent with query processing, ranking, and retrieval strategies.
Implements semantic search, keyword search, and hybrid approaches with relevance scoring.
"""

Explanation: This module docstring describes the purpose and key features of the 235 Search Agent pattern.


Imports Section

from typing import Dict, Any, List, Optional, Set
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from collections import defaultdict
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
import re  # First 20 imports shown

Explanation: The module imports necessary dependencies: – from typing import Dict, Any, List, Optional, Set: Type hints for better code documentation – from dataclasses import dataclass, field: Data class decorators for clean data structures – from datetime import datetime: Date and time utilities – from enum import Enum: Enumeration support for fixed sets of values – from langchain_core.prompts import ChatPromptTemplate: LangChain framework integration – from langchain_openai import ChatOpenAI: LangChain framework integration


Enumerations

SearchType (Line 18)

class SearchType(Enum):
    KEYWORD
    SEMANTIC
    FUZZY
    BOOLEAN
    HYBRID

Purpose: Types of search strategies.

Values: – KEYWORD: Represents keyword – SEMANTIC: Represents semantic – FUZZY: Represents fuzzy – BOOLEAN: Represents boolean – HYBRID: Represents hybrid

SearchScope (Line 27)

class SearchScope(Enum):
    TITLE
    CONTENT
    TAGS
    METADATA
    ALL

Purpose: Scope of search.

Values: – TITLE: Represents title – CONTENT: Represents content – TAGS: Represents tags – METADATA: Represents metadata – ALL: Represents all


Classes

SearchType (Line 18)

Description: Types of search strategies.

SearchScope (Line 27)

Description: Scope of search.

Document (Line 37)

Description: Document to be searched.

SearchQuery (Line 50)

Description: Processed search query.

SearchResult (Line 60)

Description: A single search result.

SearchAgent (Line 69)

Description: Search Agent – Intelligent search with multiple strategies.

Features: – Query processing and understanding – Multiple search strategies (keyword, semantic, fuzzy, boolean) – Relevance scoring and ranking – Result highlighting – Search filters – Query expansion – Search analytics

Methods:

__init__ (Line 83)
def __init__(self, name, search_type):
    """No method docstring."""

Purpose: No method docstring.

Parameters: – self: Instance reference – name: Identifier or label – search_type: Parameter for search_type

add_document (Line 102)
def add_document(self, document):
    """Add document to search index."""

Purpose: Add document to search index.

Parameters: – self: Instance reference – document: Parameter for document

_index_document (Line 108)
def _index_document(self, document):
    """Build inverted index for document."""

Purpose: Build inverted index for document.

Parameters: – self: Instance reference – document: Parameter for document

search (Line 122)
def search(self, query, max_results, scope, filters):
    """Execute search query."""

Purpose: Execute search query.

Parameters: – self: Instance reference – query: Parameter for query – max_results: Parameter for max_results – scope: Parameter for scope – filters: Parameter for filters

_process_query (Line 168)
def _process_query(self, query, scope, filters):
    """Process and understand query."""

Purpose: Process and understand query.

Parameters: – self: Instance reference – query: Parameter for query – scope: Parameter for scope – filters: Parameter for filters

_tokenize (Line 202)
def _tokenize(self, text):
    """Tokenize text into terms."""

Purpose: Tokenize text into terms.

Parameters: – self: Instance reference – text: Parameter for text

_keyword_search (Line 209)
def _keyword_search(self, query):
    """Keyword-based search."""

Purpose: Keyword-based search.

Parameters: – self: Instance reference – query: Parameter for query

_semantic_search (Line 239)
def _semantic_search(self, query):
    """Semantic/meaning-based search using LLM."""

Purpose: Semantic/meaning-based search using LLM.

Parameters: – self: Instance reference – query: Parameter for query

_fuzzy_search (Line 272)
def _fuzzy_search(self, query):
    """Fuzzy matching search."""

Purpose: Fuzzy matching search.

Parameters: – self: Instance reference – query: Parameter for query

_fuzzy_match (Line 307)
def _fuzzy_match(self, term, text):
    """Simple fuzzy matching."""

Purpose: Simple fuzzy matching.

Parameters: – self: Instance reference – term: Parameter for term – text: Parameter for text

_boolean_search (Line 321)
def _boolean_search(self, query):
    """Boolean operator search (AND, OR, NOT)."""

Purpose: Boolean operator search (AND, OR, NOT).

Parameters: – self: Instance reference – query: Parameter for query

_hybrid_search (Line 366)
def _hybrid_search(self, query):
    """Hybrid search combining multiple strategies."""

Purpose: Hybrid search combining multiple strategies.

Parameters: – self: Instance reference – query: Parameter for query

_apply_filters (Line 396)
def _apply_filters(self, results, filters):
    """Apply filters to search results."""

Purpose: Apply filters to search results.

Parameters: – self: Instance reference – results: Parameter for results – filters: Parameter for filters

_rank_results (Line 424)
def _rank_results(self, results, query):
    """Rank search results."""

Purpose: Rank search results.

Parameters: – self: Instance reference – results: Parameter for results – query: Parameter for query

_generate_highlights (Line 437)
def _generate_highlights(self, document, query):
    """Generate highlighted snippets."""

Purpose: Generate highlighted snippets.

Parameters: – self: Instance reference – document: Parameter for document – query: Parameter for query

…and 1 more methods

SearchPattern (Line 472)

Description: Search Pattern – Manage search agents for different domains.

Coordinates multiple search agents with different strategies.

Methods:

__init__ (Line 479)
def __init__(self):
    """No method docstring."""

Purpose: No method docstring.

Parameters: – self: Instance reference

create_agent (Line 482)
def create_agent(self, name, search_type):
    """Create a new search agent."""

Purpose: Create a new search agent.

Parameters: – self: Instance reference – name: Identifier or label – search_type: Parameter for search_type

run (Line 491)
def run(self, agent_name, query, max_results):
    """Execute search query."""

Purpose: Execute search query.

Parameters: – self: Instance reference – agent_name: Parameter for agent_name – query: Parameter for query – max_results: Parameter for max_results


Design Patterns Used

  • Strategy Pattern: Different execution strategies for various scenarios
  • State Pattern: Managing different states in the workflow
  • Chain of Responsibility: Sequential processing through workflow nodes
  • Observer Pattern: Tracking and monitoring pattern execution
  • Factory Pattern: Creating instances with proper configuration

Usage Example

# Initialize the pattern
pattern = SearchType(name="example", model_name="gpt-4")

# Configure and execute
result = pattern.execute(data={"key": "value"})

# Check results
print(result)

Key Concepts

  • LLM Integration: Uses LangChain/OpenAI for intelligent decision-making
  • Workflow Orchestration: LangGraph manages execution flow through nodes
  • State Management: TypedDict defines workflow state structure
  • Error Handling: Comprehensive error handling and fallback mechanisms
  • Extensibility: Designed for easy customization and extension

Extension Points

This pattern can be extended by: 1. Adding custom workflow nodes 2. Implementing additional validation logic 3. Integrating with external services 4. Customizing LLM prompts and responses 5. Adding monitoring and logging capabilities

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *