Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ jobs:
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
Expand Down Expand Up @@ -199,7 +199,7 @@ jobs:
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
Expand Down
6 changes: 3 additions & 3 deletions benchmark/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def run_cordon_analysis(
- intermediates: Dict with embeddings, scores, windows (if return_intermediates=True)
"""
from cordon.analysis.scorer import DensityAnomalyScorer
from cordon.embedding import create_vectorizer
from cordon.embedding import create_embedder
from cordon.ingestion.reader import LogFileReader
from cordon.segmentation.windower import SlidingWindowSegmenter

Expand All @@ -216,8 +216,8 @@ def run_cordon_analysis(
segmenter = SlidingWindowSegmenter()
windows = list(segmenter.segment(lines, config))

vectorizer = create_vectorizer(config)
embedded = list(vectorizer.embed_windows(windows))
embedder = create_embedder(config)
embedded = list(embedder.embed_windows(windows))

scorer = DensityAnomalyScorer()
scored = scorer.score_windows(embedded, config)
Expand Down
6 changes: 3 additions & 3 deletions benchmark/visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

from cordon import AnalysisConfig
from cordon.analysis.scorer import DensityAnomalyScorer
from cordon.embedding import create_vectorizer
from cordon.embedding import create_embedder
from cordon.ingestion.reader import LogFileReader
from cordon.segmentation.windower import SlidingWindowSegmenter

Expand Down Expand Up @@ -90,8 +90,8 @@ def get_embeddings(
window_ranges = [(w.start_line, w.end_line) for w in windows]

# Embed
vectorizer = create_vectorizer(config)
embedded = list(vectorizer.embed_windows(windows))
embedder = create_embedder(config)
embedded = list(embedder.embed_windows(windows))

# Extract embeddings matrix
embeddings = np.array([emb for _, emb in embedded])
Expand Down
28 changes: 17 additions & 11 deletions src/cordon/embedding/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,34 @@
from cordon.core.types import Embedder


def create_vectorizer(config: "AnalysisConfig") -> "Embedder":
"""Factory function to create appropriate vectorizer based on config.
def create_embedder(config: "AnalysisConfig") -> "Embedder":
"""Factory function to create the appropriate embedder for a config.

Args:
config: Analysis configuration with backend selection
config: Analysis configuration with backend selection.

Returns:
Vectorizer instance implementing the Embedder protocol
Embedder instance matching the configured backend.

Raises:
ValueError: If the backend is not recognized.
"""
if config.backend == "remote":
from cordon.embedding.remote import RemoteVectorizer
from cordon.embedding.remote import RemoteEmbedder

return RemoteVectorizer(config)
return RemoteEmbedder(config)

if config.backend == "llama-cpp":
from cordon.embedding.llama_cpp import LlamaCppVectorizer
from cordon.embedding.llama_cpp import LlamaCppEmbedder

return LlamaCppEmbedder(config)

return LlamaCppVectorizer(config)
if config.backend == "sentence-transformers":
from cordon.embedding.transformer import TransformerEmbedder

from cordon.embedding.transformer import TransformerVectorizer
return TransformerEmbedder(config)

return TransformerVectorizer(config)
raise ValueError(f"Unknown backend: {config.backend}")


__all__ = ["create_vectorizer"]
__all__ = ["create_embedder"]
62 changes: 44 additions & 18 deletions src/cordon/embedding/llama_cpp.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import logging
from collections.abc import Iterable, Iterator
from typing import Any

import numpy as np
import numpy.typing as npt
from tqdm import tqdm

from cordon.core.config import AnalysisConfig
from cordon.core.types import TextWindow
from cordon.embedding.normalize import normalize_embeddings

logger = logging.getLogger(__name__)

DEFAULT_REPO_ID = "second-state/All-MiniLM-L6-v2-Embedding-GGUF"
DEFAULT_FILENAME = "all-MiniLM-L6-v2-Q4_K_M.gguf"


class LlamaCppVectorizer:
class LlamaCppEmbedder:
"""Convert text windows to embeddings using llama.cpp GGUF models."""

def __init__(self, config: AnalysisConfig) -> None:
"""Initialize the vectorizer with llama.cpp model.
"""Initialize the embedder with llama.cpp model.

Args:
config: Analysis configuration specifying model and parameters
config: Analysis configuration specifying model and parameters.
"""
self.config = config
self.model_path = config.model_path if config.model_path else self._get_default_model()
Expand Down Expand Up @@ -46,28 +51,49 @@ def embed_windows(
"""Embed text windows into vector representations.

Args:
windows: Iterable of text windows to embed
windows: Iterable of text windows to embed.

Yields:
Tuples of (window, embedding) where embeddings are normalized
numpy arrays
Tuples of (window, embedding) where embeddings are L2-normalized
numpy arrays.
"""
for window in windows:
result = self.model.create_embedding(window.content)
embedding_list = result["data"][0]["embedding"]
embedding_array = np.array(embedding_list, dtype=np.float32)

norm = np.linalg.norm(embedding_array)
if norm > 0:
embedding_array = embedding_array / norm
window_list = list(windows)
if not window_list:
return

batch_size = self.config.batch_size
total_batches = (len(window_list) + batch_size - 1) // batch_size

for batch_start in tqdm(
range(0, len(window_list), batch_size),
desc="Generating embeddings",
total=total_batches,
unit="batch",
):
batch = window_list[batch_start : batch_start + batch_size]
texts = [w.content for w in batch]

try:
result = self.model.create_embedding(texts)
except Exception as error:
raise RuntimeError(
f"llama.cpp embedding failed on batch starting at window "
f"{batch[0].window_id}: {error}"
) from error

embeddings = np.array(
[item["embedding"] for item in result["data"]],
dtype=np.float32,
)
embeddings = normalize_embeddings(embeddings)

yield window, embedding_array
yield from zip(batch, embeddings, strict=False)

def _get_default_model(self) -> str:
"""Get path to default GGUF model, downloading if necessary.

Returns:
Path to the model file
Path to the model file.
"""
try:
from huggingface_hub import hf_hub_download
Expand All @@ -78,12 +104,12 @@ def _get_default_model(self) -> str:
) from error

try:
print(f"Downloading default GGUF model: {DEFAULT_FILENAME}")
logger.info("Downloading default GGUF model: %s", DEFAULT_FILENAME)
model_path = hf_hub_download(
repo_id=DEFAULT_REPO_ID,
filename=DEFAULT_FILENAME,
)
print(f"Model downloaded to: {model_path}")
logger.info("Model downloaded to: %s", model_path)
return str(model_path)
except Exception as error:
raise RuntimeError(
Expand Down
35 changes: 35 additions & 0 deletions src/cordon/embedding/normalize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared embedding normalization utilities."""

from typing import Any

import numpy as np
import numpy.typing as npt


def normalize_embeddings(
embeddings: npt.NDArray[np.floating[Any]],
) -> npt.NDArray[np.floating[Any]]:
"""L2-normalize embedding vectors.

Handles both single vectors (1D) and batches (2D). Zero vectors
are left as-is to avoid division by zero.

Args:
embeddings: Array of shape (dim,) or (batch, dim).

Returns:
L2-normalized array of the same shape.
"""
result: npt.NDArray[np.floating[Any]]

if embeddings.ndim == 1:
norm: np.floating[Any] = np.linalg.norm(embeddings)
if norm > 0:
result = embeddings / norm
return result
return embeddings

norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
safe_norms = np.maximum(norms, 1e-10)
result = embeddings / safe_norms
return result
41 changes: 27 additions & 14 deletions src/cordon/embedding/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@

from cordon.core.config import AnalysisConfig
from cordon.core.types import TextWindow
from cordon.embedding.normalize import normalize_embeddings


class RemoteVectorizer:
class RemoteEmbedder:
"""Convert text windows to embeddings using remote API providers via LiteLLM."""

def __init__(self, config: AnalysisConfig) -> None:
"""Initialize the vectorizer with remote API configuration.
"""Initialize the embedder with remote API configuration.

Args:
config: Analysis configuration with remote backend settings
config: Analysis configuration with remote backend settings.
"""
self.config = config
litellm.set_verbose = False
Expand All @@ -33,10 +34,10 @@ def embed_windows(
"""Embed text windows into vector representations using remote API.

Args:
windows: Iterable of text windows to embed
windows: Iterable of text windows to embed.

Yields:
Tuples of (window, embedding) where embeddings are L2 normalized
Tuples of (window, embedding) where embeddings are L2-normalized.
"""
window_list = list(windows)

Expand All @@ -55,6 +56,7 @@ def embed_windows(
batch = window_list[batch_start_idx : batch_start_idx + batch_size]
texts = [window.content for window in batch]

batch_results: list[tuple[TextWindow, npt.NDArray[np.floating[Any]]]] = []
try:
response = litellm.embedding(
model=self.config.model_name,
Expand All @@ -65,15 +67,20 @@ def embed_windows(
)

embeddings_data = response.data
for window, embedding_obj in zip(batch, embeddings_data, strict=True):
embedding_list = embedding_obj["embedding"]
embedding_array = np.array(embedding_list, dtype=np.float32)

norm = np.linalg.norm(embedding_array)
if norm > 0:
embedding_array = embedding_array / norm
try:
raw_embeddings = np.array(
[item["embedding"] for item in embeddings_data],
dtype=np.float32,
)
except (KeyError, TypeError) as error:
raise RuntimeError(
f"Unexpected API response format from model '{self.config.model_name}'. "
f"Expected 'embedding' field in response data: {error}"
) from error

yield window, embedding_array
normalized = normalize_embeddings(raw_embeddings)
batch_results = list(zip(batch, normalized, strict=True))

except AuthenticationError as error:
raise RuntimeError(
Expand All @@ -88,9 +95,15 @@ def embed_windows(
except Timeout as error:
raise RuntimeError(
f"Request timeout for model '{self.config.model_name}'. "
f"Try increasing request_timeout (current: {self.config.request_timeout}s). Error: {error}"
f"Try increasing request_timeout (current: {self.config.request_timeout}s). "
f"Error: {error}"
) from error
except RuntimeError:
raise
except Exception as error:
raise RuntimeError(
f"Error calling remote embedding API for model '{self.config.model_name}': {error}"
f"Error calling remote embedding API for model "
f"'{self.config.model_name}': {error}"
) from error

yield from batch_results
Loading