diff --git a/.env.example b/.env.example index 9d8c079..851a3f6 100644 --- a/.env.example +++ b/.env.example @@ -74,18 +74,31 @@ JWT_SECRET_KEY=your_jwt_secret_key_here # SEMANTIC SEARCH SETTINGS (Optional) # ============================================================================= -# OpenRouter API Key for semantic search functionality +# Embedding provider for the semantic_search tool. +# Pick exactly one of: OpenRouter (hosted) or Local (your own server). + +# --- Option A: OpenRouter (hosted, default) ----------------------------------- # Get your API key from: https://openrouter.ai/keys -# If not set, semantic search tool will be disabled +# If neither this nor EMBEDDING_PROVIDER=local is set, semantic search is off. OPENROUTER_API_KEY=sk-or-v1-your_openrouter_api_key_here -# Optional: override the embedding model and dimension. +# Optional: override the OpenRouter embedding model and dimension. # Defaults: google/gemini-embedding-001 at 3072 dims (paid on OpenRouter). -# Pick any embedding model from https://openrouter.ai/models?modality=embedding +# Pick any model from https://openrouter.ai/models?modality=embedding # and set the dimension to that model's output size — they must match. # OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001 # OPENROUTER_EMBEDDING_DIMENSION=3072 +# --- Option B: Local OpenAI-compatible server (Ollama / llama.cpp / vLLM) ----- +# Uncomment to use your own server instead of OpenRouter (no API key required). +# Defaults target Ollama with nomic-embed-text. For Turkish, bge-m3 (1024 dims) +# tends to work better — pull it with: `ollama pull bge-m3` +# EMBEDDING_PROVIDER=local +# LOCAL_EMBEDDING_BASE_URL=http://localhost:11434/v1 +# LOCAL_EMBEDDING_MODEL=nomic-embed-text +# LOCAL_EMBEDDING_DIMENSION=768 +# LOCAL_EMBEDDING_API_KEY= # most local servers ignore this + # ============================================================================= # USAGE INSTRUCTIONS # ============================================================================= diff --git a/mcp_server_main.py b/mcp_server_main.py index 44ef209..20e6e06 100644 --- a/mcp_server_main.py +++ b/mcp_server_main.py @@ -265,17 +265,18 @@ from bedesten_mcp_module.models import ( ) from bedesten_mcp_module.enums import BirimAdiEnum -# Semantic Search Module Imports (conditional based on OPENROUTER_API_KEY) -from semantic_search.embedder import is_openrouter_available -SEMANTIC_SEARCH_AVAILABLE = is_openrouter_available() +# Semantic Search Module Imports (enabled if any embedding provider is configured) +from semantic_search.embedder import is_semantic_search_available, is_local_embedding_configured +SEMANTIC_SEARCH_AVAILABLE = is_semantic_search_available() if SEMANTIC_SEARCH_AVAILABLE: - from semantic_search.embedder import OpenRouterEmbedder + from semantic_search.embedder import get_embedder from semantic_search.vector_store import VectorStore from semantic_search.processor import DocumentProcessor - logger.info("Semantic search enabled (OPENROUTER_API_KEY found)") + provider = "local" if is_local_embedding_configured() else "openrouter" + logger.info(f"Semantic search enabled (provider={provider})") else: - logger.info("Semantic search disabled (OPENROUTER_API_KEY not set)") + logger.info("Semantic search disabled (no embedding provider configured)") from danistay_mcp_module.client import DanistayApiClient from emsal_mcp_module.client import EmsalApiClient @@ -1279,8 +1280,9 @@ YANLIŞ KULLANIM: logger.info(f"Semantic search tool called with initial_keyword: {initial_keyword}, query: {query}") try: - # Initialize components - embedder = OpenRouterEmbedder() + # Initialize components (provider chosen via EMBEDDING_PROVIDER / + # OPENROUTER_API_KEY env vars) + embedder = get_embedder() vector_store = VectorStore(dimension=embedder.dimension) processor = DocumentProcessor(chunk_size=1500, chunk_overlap=300) diff --git a/semantic_search/__init__.py b/semantic_search/__init__.py index e773900..5d3eae3 100644 --- a/semantic_search/__init__.py +++ b/semantic_search/__init__.py @@ -1,7 +1,23 @@ # semantic_search/__init__.py -from .embedder import OpenRouterEmbedder, is_openrouter_available +from .embedder import ( + OpenRouterEmbedder, + LocalEmbedder, + get_embedder, + is_openrouter_available, + is_local_embedding_configured, + is_semantic_search_available, +) from .vector_store import VectorStore from .processor import DocumentProcessor -__all__ = ['OpenRouterEmbedder', 'is_openrouter_available', 'VectorStore', 'DocumentProcessor'] +__all__ = [ + 'OpenRouterEmbedder', + 'LocalEmbedder', + 'get_embedder', + 'is_openrouter_available', + 'is_local_embedding_configured', + 'is_semantic_search_available', + 'VectorStore', + 'DocumentProcessor', +] diff --git a/semantic_search/embedder.py b/semantic_search/embedder.py index bb71656..d49c6c1 100644 --- a/semantic_search/embedder.py +++ b/semantic_search/embedder.py @@ -2,87 +2,70 @@ import logging import os -from typing import List, Optional +from typing import Dict, List, Optional import numpy as np logger = logging.getLogger(__name__) +# OpenRouter defaults (preserve backward compatibility) DEFAULT_MODEL = "google/gemini-embedding-001" DEFAULT_DIMENSION = 3072 +# Local provider defaults — Ollama with nomic-embed-text out of the box. +# Override via LOCAL_EMBEDDING_BASE_URL / LOCAL_EMBEDDING_MODEL / +# LOCAL_EMBEDDING_DIMENSION when using a different server or model +# (e.g. llama.cpp's server, vLLM, LM Studio, or a different Ollama model +# such as bge-m3 — better for Turkish — at 1024 dimensions). +LOCAL_DEFAULT_BASE_URL = "http://localhost:11434/v1" +LOCAL_DEFAULT_MODEL = "nomic-embed-text" +LOCAL_DEFAULT_DIMENSION = 768 + def is_openrouter_available() -> bool: """Check if OpenRouter API key is available.""" return bool(os.getenv("OPENROUTER_API_KEY")) -class OpenRouterEmbedder: +def is_local_embedding_configured() -> bool: + """Check if the user opted into a local embedding endpoint.""" + return os.getenv("EMBEDDING_PROVIDER", "").strip().lower() == "local" + + +def is_semantic_search_available() -> bool: + """Returns True if any embedding provider is configured.""" + return is_local_embedding_configured() or is_openrouter_available() + + +def _coerce_dimension(value, env_name: str, default: int) -> int: + """Parse a dimension value (int or str) with clear error messages.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError) as e: + raise ValueError( + f"{env_name} must be an integer, got {value!r}" + ) from e + if parsed <= 0: + raise ValueError(f"Embedding dimension must be positive, got {parsed}") + return parsed + + +class _BaseOpenAICompatibleEmbedder: """ - Embedder using OpenRouter's embedding API. - - The model and dimension are configurable so users can pick any OpenRouter - embedding model (e.g. when one becomes paid or when a different model fits - the budget better). Configuration precedence: explicit constructor args > - environment variables > defaults. - - Environment variables: - OPENROUTER_API_KEY (required): OpenRouter credential - OPENROUTER_EMBEDDING_MODEL (optional): override the embedding model id - OPENROUTER_EMBEDDING_DIMENSION (optional): override the vector size - - Defaults preserve backward compatibility: ``google/gemini-embedding-001`` - at 3072 dimensions. + Shared encode/similarity logic for embedders backed by the OpenAI Python + SDK. Subclasses configure ``client``, ``model``, ``dimension``, and + optionally ``_extra_headers`` (e.g. OpenRouter ranking headers). """ - def __init__(self, model: Optional[str] = None, dimension: Optional[int] = None): - """ - Initialize OpenRouter Embedder. + # Subclasses may override; sent on every embeddings.create call when set. + _extra_headers: Dict[str, str] = {} - Args: - model: OpenRouter embedding model id. Falls back to - OPENROUTER_EMBEDDING_MODEL env var, then DEFAULT_MODEL. - dimension: Output vector size. Falls back to - OPENROUTER_EMBEDDING_DIMENSION env var, then DEFAULT_DIMENSION. - Must match the chosen model's actual output size — the vector - store and similarity math rely on it. - - Raises: - ValueError: If OPENROUTER_API_KEY is not set or dimension is invalid - ImportError: If openai package is not installed - """ - api_key = os.getenv("OPENROUTER_API_KEY") - if not api_key: - raise ValueError("OPENROUTER_API_KEY environment variable is not set") - - try: - from openai import OpenAI - except ImportError: - raise ImportError("openai package is required. Install with: pip install openai") - - self.client = OpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=api_key, - ) - self.model = model or os.getenv("OPENROUTER_EMBEDDING_MODEL") or DEFAULT_MODEL - - dim_value = dimension if dimension is not None else os.getenv("OPENROUTER_EMBEDDING_DIMENSION") - if dim_value is None: - self.dimension = DEFAULT_DIMENSION - else: - try: - self.dimension = int(dim_value) - except (TypeError, ValueError) as e: - raise ValueError( - f"OPENROUTER_EMBEDDING_DIMENSION must be an integer, got {dim_value!r}" - ) from e - if self.dimension <= 0: - raise ValueError(f"Embedding dimension must be positive, got {self.dimension}") - - logger.info( - f"OpenRouter Embedder initialized with model: {self.model} " - f"(dimension={self.dimension})" - ) + # Set by subclasses + client = None + model: str = "" + dimension: int = 0 def encode_query(self, query: str, task: str = "search result") -> np.ndarray: """ @@ -95,7 +78,6 @@ class OpenRouterEmbedder: Returns: Numpy array of embeddings (``self.dimension`` elements). """ - # Apply query prompt template text = f"task: {task} | query: {query}" try: @@ -103,10 +85,7 @@ class OpenRouterEmbedder: model=self.model, input=text, encoding_format="float", - extra_headers={ - "HTTP-Referer": "https://yargimcp.com", - "X-Title": "Yargi MCP Server", - } + extra_headers=self._extra_headers or None, ) embedding = np.array(response.data[0].embedding, dtype=np.float32) @@ -125,7 +104,7 @@ class OpenRouterEmbedder: def encode_documents(self, documents: List[str], titles: Optional[List[str]] = None) -> np.ndarray: """ - Encode multiple documents with batch API call. + Encode multiple documents with a batch API call. Args: documents: List of document texts @@ -137,28 +116,22 @@ class OpenRouterEmbedder: if not documents: return np.array([]) - # Apply document prompt template texts = [] for i, doc in enumerate(documents): title = titles[i] if titles and i < len(titles) else "none" - text = f"title: {title} | text: {doc}" - texts.append(text) + texts.append(f"title: {title} | text: {doc}") try: response = self.client.embeddings.create( model=self.model, input=texts, encoding_format="float", - extra_headers={ - "HTTP-Referer": "https://yargimcp.com", - "X-Title": "Yargi MCP Server", - } + extra_headers=self._extra_headers or None, ) - # Extract embeddings in order embeddings = np.array( [d.embedding for d in sorted(response.data, key=lambda x: x.index)], - dtype=np.float32 + dtype=np.float32, ) # L2 normalize each embedding for cosine similarity @@ -183,11 +156,139 @@ class OpenRouterEmbedder: Returns: Similarity scores (N,) """ - # Ensure query is 2D for matrix multiplication if len(query_embedding.shape) == 1: query_embedding = query_embedding.reshape(1, -1) - # Compute cosine similarity (embeddings are already normalized) + # Embeddings are already L2-normalized. similarities = np.dot(document_embeddings, query_embedding.T).squeeze() - return similarities + + +class OpenRouterEmbedder(_BaseOpenAICompatibleEmbedder): + """ + Embedder using OpenRouter's embedding API. + + The model and dimension are configurable so users can pick any OpenRouter + embedding model (e.g. when one becomes paid). Configuration precedence: + explicit constructor args > environment variables > defaults. + + Environment variables: + OPENROUTER_API_KEY (required): OpenRouter credential + OPENROUTER_EMBEDDING_MODEL (optional): override the embedding model id + OPENROUTER_EMBEDDING_DIMENSION (optional): override the vector size + + Defaults preserve backward compatibility: ``google/gemini-embedding-001`` + at 3072 dimensions. + """ + + _extra_headers = { + "HTTP-Referer": "https://yargimcp.com", + "X-Title": "Yargi MCP Server", + } + + def __init__(self, model: Optional[str] = None, dimension: Optional[int] = None): + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + raise ValueError("OPENROUTER_API_KEY environment variable is not set") + + try: + from openai import OpenAI + except ImportError: + raise ImportError("openai package is required. Install with: pip install openai") + + self.client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=api_key, + ) + self.model = model or os.getenv("OPENROUTER_EMBEDDING_MODEL") or DEFAULT_MODEL + self.dimension = _coerce_dimension( + dimension if dimension is not None else os.getenv("OPENROUTER_EMBEDDING_DIMENSION"), + "OPENROUTER_EMBEDDING_DIMENSION", + DEFAULT_DIMENSION, + ) + + logger.info( + f"OpenRouter Embedder initialized with model: {self.model} " + f"(dimension={self.dimension})" + ) + + +class LocalEmbedder(_BaseOpenAICompatibleEmbedder): + """ + Embedder for a local OpenAI-compatible embedding server — Ollama, + llama.cpp, vLLM, LM Studio, etc. Zero new Python dependencies; just + point the existing OpenAI SDK at a local base URL. + + Environment variables: + EMBEDDING_PROVIDER=local (selects this provider) + LOCAL_EMBEDDING_BASE_URL (default: http://localhost:11434/v1) + LOCAL_EMBEDDING_MODEL (default: nomic-embed-text) + LOCAL_EMBEDDING_DIMENSION (default: 768) + LOCAL_EMBEDDING_API_KEY (optional; ignored by most local servers) + + Setup (Ollama): + $ ollama serve + $ ollama pull nomic-embed-text # or bge-m3 for better Turkish + + The dimension MUST match the model's actual output size (e.g. 768 for + nomic-embed-text, 1024 for bge-m3, 1024 for mxbai-embed-large). + """ + + def __init__( + self, + base_url: Optional[str] = None, + model: Optional[str] = None, + dimension: Optional[int] = None, + api_key: Optional[str] = None, + ): + try: + from openai import OpenAI + except ImportError: + raise ImportError("openai package is required. Install with: pip install openai") + + self.base_url = ( + base_url + or os.getenv("LOCAL_EMBEDDING_BASE_URL") + or LOCAL_DEFAULT_BASE_URL + ) + # Most local servers don't validate the key — use a placeholder so + # the OpenAI SDK doesn't error on the missing-key check. + effective_key = ( + api_key + or os.getenv("LOCAL_EMBEDDING_API_KEY") + or "no-key-needed" + ) + + self.client = OpenAI(base_url=self.base_url, api_key=effective_key) + self.model = model or os.getenv("LOCAL_EMBEDDING_MODEL") or LOCAL_DEFAULT_MODEL + self.dimension = _coerce_dimension( + dimension if dimension is not None else os.getenv("LOCAL_EMBEDDING_DIMENSION"), + "LOCAL_EMBEDDING_DIMENSION", + LOCAL_DEFAULT_DIMENSION, + ) + + logger.info( + f"Local Embedder initialized: model={self.model} " + f"base_url={self.base_url} dimension={self.dimension}" + ) + + +def get_embedder(): + """ + Factory that picks the embedder based on EMBEDDING_PROVIDER. + + - ``EMBEDDING_PROVIDER=local`` -> ``LocalEmbedder`` + - otherwise -> ``OpenRouterEmbedder`` (requires OPENROUTER_API_KEY) + + Raises: + ValueError: If no provider is configured (neither local nor OpenRouter). + """ + if is_local_embedding_configured(): + return LocalEmbedder() + if is_openrouter_available(): + return OpenRouterEmbedder() + raise ValueError( + "No embedding provider configured. Set OPENROUTER_API_KEY for hosted " + "embeddings, or EMBEDDING_PROVIDER=local (with LOCAL_EMBEDDING_* " + "env vars) for a local OpenAI-compatible server like Ollama." + )