feat(semantic_search): support local OpenAI-compatible embedding servers (#22)

Adds a LocalEmbedder that targets any OpenAI-compatible embedding
endpoint (Ollama, llama.cpp, vLLM, LM Studio, ...). Zero new
Python dependencies — reuses the existing openai SDK with a
custom base_url. Defaults to Ollama at http://localhost:11434/v1
with nomic-embed-text @ 768 dims; override via env vars for other
servers/models (e.g. bge-m3 @ 1024 dims for better Turkish).

Refactors the shared encode/similarity logic into a private base
class so OpenRouterEmbedder and LocalEmbedder don't duplicate ~50
lines. OpenRouter keeps its ranking headers; local sends none.

Adds get_embedder() factory selecting the provider based on
EMBEDDING_PROVIDER (local) or OPENROUTER_API_KEY presence, and
is_semantic_search_available() that returns True for either path.
mcp_server_main now uses these so the semantic_search tool is
exposed when only a local server is configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saidsurucu
2026-05-03 01:50:47 +03:00
co-authored by Claude Opus 4.7
parent 42731a2c03
commit fb29146755
4 changed files with 227 additions and 95 deletions
+17 -4
View File
@@ -74,18 +74,31 @@ JWT_SECRET_KEY=your_jwt_secret_key_here
# SEMANTIC SEARCH SETTINGS (Optional) # 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 # 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 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). # 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. # and set the dimension to that model's output size — they must match.
# OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001 # OPENROUTER_EMBEDDING_MODEL=google/gemini-embedding-001
# OPENROUTER_EMBEDDING_DIMENSION=3072 # 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 # USAGE INSTRUCTIONS
# ============================================================================= # =============================================================================
+10 -8
View File
@@ -265,17 +265,18 @@ from bedesten_mcp_module.models import (
) )
from bedesten_mcp_module.enums import BirimAdiEnum from bedesten_mcp_module.enums import BirimAdiEnum
# Semantic Search Module Imports (conditional based on OPENROUTER_API_KEY) # Semantic Search Module Imports (enabled if any embedding provider is configured)
from semantic_search.embedder import is_openrouter_available from semantic_search.embedder import is_semantic_search_available, is_local_embedding_configured
SEMANTIC_SEARCH_AVAILABLE = is_openrouter_available() SEMANTIC_SEARCH_AVAILABLE = is_semantic_search_available()
if 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.vector_store import VectorStore
from semantic_search.processor import DocumentProcessor 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: 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 danistay_mcp_module.client import DanistayApiClient
from emsal_mcp_module.client import EmsalApiClient 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}") logger.info(f"Semantic search tool called with initial_keyword: {initial_keyword}, query: {query}")
try: try:
# Initialize components # Initialize components (provider chosen via EMBEDDING_PROVIDER /
embedder = OpenRouterEmbedder() # OPENROUTER_API_KEY env vars)
embedder = get_embedder()
vector_store = VectorStore(dimension=embedder.dimension) vector_store = VectorStore(dimension=embedder.dimension)
processor = DocumentProcessor(chunk_size=1500, chunk_overlap=300) processor = DocumentProcessor(chunk_size=1500, chunk_overlap=300)
+18 -2
View File
@@ -1,7 +1,23 @@
# semantic_search/__init__.py # 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 .vector_store import VectorStore
from .processor import DocumentProcessor 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',
]
+177 -76
View File
@@ -2,87 +2,70 @@
import logging import logging
import os import os
from typing import List, Optional from typing import Dict, List, Optional
import numpy as np import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# OpenRouter defaults (preserve backward compatibility)
DEFAULT_MODEL = "google/gemini-embedding-001" DEFAULT_MODEL = "google/gemini-embedding-001"
DEFAULT_DIMENSION = 3072 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: def is_openrouter_available() -> bool:
"""Check if OpenRouter API key is available.""" """Check if OpenRouter API key is available."""
return bool(os.getenv("OPENROUTER_API_KEY")) 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."""
Embedder using OpenRouter's embedding API. return os.getenv("EMBEDDING_PROVIDER", "").strip().lower() == "local"
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: def is_semantic_search_available() -> bool:
OPENROUTER_API_KEY (required): OpenRouter credential """Returns True if any embedding provider is configured."""
OPENROUTER_EMBEDDING_MODEL (optional): override the embedding model id return is_local_embedding_configured() or is_openrouter_available()
OPENROUTER_EMBEDDING_DIMENSION (optional): override the vector size
Defaults preserve backward compatibility: ``google/gemini-embedding-001``
at 3072 dimensions.
"""
def __init__(self, model: Optional[str] = None, dimension: Optional[int] = None):
"""
Initialize OpenRouter Embedder.
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")
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: try:
from openai import OpenAI parsed = int(value)
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: except (TypeError, ValueError) as e:
raise ValueError( raise ValueError(
f"OPENROUTER_EMBEDDING_DIMENSION must be an integer, got {dim_value!r}" f"{env_name} must be an integer, got {value!r}"
) from e ) from e
if self.dimension <= 0: if parsed <= 0:
raise ValueError(f"Embedding dimension must be positive, got {self.dimension}") raise ValueError(f"Embedding dimension must be positive, got {parsed}")
return parsed
logger.info(
f"OpenRouter Embedder initialized with model: {self.model} " class _BaseOpenAICompatibleEmbedder:
f"(dimension={self.dimension})" """
) 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).
"""
# Subclasses may override; sent on every embeddings.create call when set.
_extra_headers: Dict[str, str] = {}
# Set by subclasses
client = None
model: str = ""
dimension: int = 0
def encode_query(self, query: str, task: str = "search result") -> np.ndarray: def encode_query(self, query: str, task: str = "search result") -> np.ndarray:
""" """
@@ -95,7 +78,6 @@ class OpenRouterEmbedder:
Returns: Returns:
Numpy array of embeddings (``self.dimension`` elements). Numpy array of embeddings (``self.dimension`` elements).
""" """
# Apply query prompt template
text = f"task: {task} | query: {query}" text = f"task: {task} | query: {query}"
try: try:
@@ -103,10 +85,7 @@ class OpenRouterEmbedder:
model=self.model, model=self.model,
input=text, input=text,
encoding_format="float", encoding_format="float",
extra_headers={ extra_headers=self._extra_headers or None,
"HTTP-Referer": "https://yargimcp.com",
"X-Title": "Yargi MCP Server",
}
) )
embedding = np.array(response.data[0].embedding, dtype=np.float32) 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: 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: Args:
documents: List of document texts documents: List of document texts
@@ -137,28 +116,22 @@ class OpenRouterEmbedder:
if not documents: if not documents:
return np.array([]) return np.array([])
# Apply document prompt template
texts = [] texts = []
for i, doc in enumerate(documents): for i, doc in enumerate(documents):
title = titles[i] if titles and i < len(titles) else "none" title = titles[i] if titles and i < len(titles) else "none"
text = f"title: {title} | text: {doc}" texts.append(f"title: {title} | text: {doc}")
texts.append(text)
try: try:
response = self.client.embeddings.create( response = self.client.embeddings.create(
model=self.model, model=self.model,
input=texts, input=texts,
encoding_format="float", encoding_format="float",
extra_headers={ extra_headers=self._extra_headers or None,
"HTTP-Referer": "https://yargimcp.com",
"X-Title": "Yargi MCP Server",
}
) )
# Extract embeddings in order
embeddings = np.array( embeddings = np.array(
[d.embedding for d in sorted(response.data, key=lambda x: x.index)], [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 # L2 normalize each embedding for cosine similarity
@@ -183,11 +156,139 @@ class OpenRouterEmbedder:
Returns: Returns:
Similarity scores (N,) Similarity scores (N,)
""" """
# Ensure query is 2D for matrix multiplication
if len(query_embedding.shape) == 1: if len(query_embedding.shape) == 1:
query_embedding = query_embedding.reshape(1, -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() similarities = np.dot(document_embeddings, query_embedding.T).squeeze()
return similarities 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."
)