feat: optimise embeds

This commit is contained in:
Alex committed 2026-08-26 14:46:19 +01:00
1 parent e0aff39a1b
commit 8380f9bb47
28 files changed
+1992 -349

No files matched your search

+14 -2
View File
@@ -11,8 +11,20 @@ INTERNAL_KEY=<internal key for worker-to-backend authentication>
# NOVITA_API_KEY=<your-novita-api-key>
# OPEN_ROUTER_API_KEY=<your-openrouter-api-key>
# Remote Embeddings (Optional - for using a remote embeddings API instead of local SentenceTransformer)
# When set, the app will use the remote API and won't load SentenceTransformer (saves RAM)
# Embedding model. New installs should use granite: multilingual, 32k context,
# and the same 768 dimensions as the legacy model.
#
# UPGRADING AN EXISTING INSTALL? Leave this commented out. Vectors already in
# your index were produced by the old model, and changing this without
# re-embedding gives you retrieval against a different vector space -- which
# fails silently, because both models are 768-dimensional. To switch, set this
# and then run:
# python -m application.scripts.reembed
EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2
# Remote Embeddings (Optional - for using a remote embeddings API instead of
# running the model in-process). When set, the app calls the remote API and
# never loads a local model, which keeps the API and worker containers small.
EMBEDDINGS_BASE_URL=
EMBEDDINGS_KEY=
+14
View File
@@ -96,6 +96,20 @@ ruff check .
python -m pytest
```
On **macOS**, run the suite with `KMP_DUPLICATE_LIB_OK=TRUE`:
```bash
KMP_DUPLICATE_LIB_OK=TRUE python -m pytest
```
`faiss-cpu` and `torch` each ship their own LLVM OpenMP runtime, and loading
both into one process makes `libomp.dylib` abort the interpreter
(`OMP: Error #15`). It is a macOS-only packaging clash, not a code fault: Linux
resolves both to `libgomp`, which tolerates duplicates, so CI (`ubuntu-latest`)
and the Docker images are unaffected. Without the variable, whether the run
aborts depends on which tests happen to load faiss and torch in the same
process, so a green run on one selection and an abort on another is expected.
### Frontend changes
```bash
+9 -7
View File
@@ -17,11 +17,6 @@ RUN if [ -f /usr/bin/python3.12 ]; then \
echo "Python 3.12 not found"; exit 1; \
fi
# Download and unzip the model
RUN wget https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip && \
unzip mpnet-base-v2.zip -d models && \
rm mpnet-base-v2.zip
# Install Rust
RUN wget -q -O - https://sh.rustup.rs | sh -s -- -y
@@ -42,6 +37,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir tiktoken && \
pip install --no-cache-dir -r requirements.txt
# Final Stage
FROM ubuntu:24.04 as final
@@ -67,11 +63,17 @@ RUN groupadd -r appuser && \
# Copy the virtual environment and model from the builder stage
COPY --from=builder /venv /venv
COPY --from=builder /models /app/models
# Copy your application code
COPY . /app/application
# Pre-fetch the embedding models into FastEmbed's cache so a fresh container
# does not download on first ingest and an air-gapped install works at all.
# Both defaults are baked: an upgraded deployment keeps using mpnet until it
# runs the re-embed script, while a new one starts on granite.
ENV EMBEDDINGS_CACHE_DIR=/app/models
ARG EMBEDDINGS_PREFETCH=""
RUN PYTHONPATH=/app /venv/bin/python -m application.scripts.prefetch_models ${EMBEDDINGS_PREFETCH}
# Change the ownership of the /app directory to the appuser
RUN mkdir -p /app/application/inputs/local
+12
View File
@@ -45,11 +45,23 @@ class Settings(BaseSettings):
LLM_PROVIDER: str = "docsgpt"
LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo
# Deliberately the legacy model, not the current recommendation. An
# existing deployment that never pinned EMBEDDINGS_NAME falls through to
# this default, and its stored vectors were produced by this model --
# changing the default here would silently retrieve against a different
# vector space (both are 768-dimensional, so no dimension check fires).
# New installs get granite from .env-template / setup.sh; existing ones
# switch by setting this and running application.scripts.reembed.
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible)
EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY)
EMBEDDINGS_MAX_INPUT_TOKENS: Optional[int] = None # truncate each remote embed input to N tokens (overflow lost)
EMBEDDINGS_BATCH_SIZE: int = 32 # chunks per embed request during ingest (1 = legacy per-chunk behaviour)
# Intra-op threads for the local ONNX runner. None = every core. ONNX Runtime
# scales sub-linearly across threads, so several single-threaded worker
# processes beat one many-threaded process on the same cores.
EMBEDDINGS_THREADS: Optional[int] = None
EMBEDDINGS_CACHE_DIR: Optional[str] = None # where FastEmbed caches model artifacts
GITHUB_INGEST_MAX_FILE_BYTES: int = 1048576 # skip repo blobs larger than this (0 = no cap)
GITHUB_INGEST_MAX_WORKERS: int = 8 # parallel file fetches per GitHub repo ingest
# Optional directory of operator-supplied model YAMLs, loaded after the
+38 -24
View File
@@ -3,7 +3,7 @@ from typing import List, Tuple
import logging
from application.parser.chunking_creator import ChunkerCreator
from application.parser.schema.base import Document
from application.utils import get_encoding
from application.parser.tokenization import get_token_counter
logger = logging.getLogger(__name__)
@@ -27,7 +27,10 @@ class Chunker:
self.max_tokens = max_tokens
self.min_tokens = min_tokens
self.duplicate_headers = duplicate_headers
self.encoding = get_encoding()
# Counted in the embedding model's tokenizer, not cl100k: ``max_tokens``
# is compared against a limit the embedding server enforces in its own
# units, so counting in any other unit is a guess.
self.counter = get_token_counter()
def separate_header_and_body(self, text: str) -> Tuple[str, str]:
header_pattern = r"^(.*?\n){3}"
@@ -42,28 +45,40 @@ class Chunker:
def split_document(self, doc: Document) -> List[Document]:
split_docs = []
header, body = self.separate_header_and_body(doc.text)
header_tokens = self.encoding.encode_ordinary(header) if header else []
body_tokens = self.encoding.encode_ordinary(body)
"""Split one oversized document into ``max_tokens``-sized chunks.
current_position = 0
part_index = 0
while current_position < len(body_tokens):
end_position = current_position + self.max_tokens - len(header_tokens)
chunk_tokens = (header_tokens + body_tokens[current_position:end_position]
if self.duplicate_headers or part_index == 0 else body_tokens[current_position:end_position])
chunk_text = self.encoding.decode(chunk_tokens)
new_doc = Document(
text=chunk_text,
doc_id=f"{doc.doc_id}-{part_index}",
embedding=doc.embedding,
extra_info={**(doc.extra_info or {}), "token_count": len(chunk_tokens)}
Pieces are sliced out of the original text rather than decoded back
from token ids. WordPiece tokenizers normalise as they decode --
all-mpnet-base-v2 lowercases -- so a decode round-trip would rewrite
every stored document.
"""
header, body = self.separate_header_and_body(doc.text)
header_tokens = self.counter.count(header) if header else 0
# A chunk carrying the header has that much less room for body text.
with_header_budget = max(1, self.max_tokens - header_tokens)
if self.duplicate_headers:
body_pieces = self.counter.split(body, with_header_budget)
else:
body_pieces = self.counter.split(
body, self.max_tokens, first_max_tokens=with_header_budget
)
split_docs = []
for part_index, piece in enumerate(body_pieces):
include_header = bool(header) and (self.duplicate_headers or part_index == 0)
chunk_text = f"{header}{piece}" if include_header else piece
split_docs.append(
Document(
text=chunk_text,
doc_id=f"{doc.doc_id}-{part_index}",
embedding=doc.embedding,
extra_info={
**(doc.extra_info or {}),
"token_count": self.counter.count(chunk_text),
},
)
)
split_docs.append(new_doc)
current_position = end_position
part_index += 1
header_tokens = []
return split_docs
def classic_chunk(self, documents: List[Document]) -> List[Document]:
@@ -71,8 +86,7 @@ class Chunker:
i = 0
while i < len(documents):
doc = documents[i]
tokens = self.encoding.encode_ordinary(doc.text)
token_count = len(tokens)
token_count = self.counter.count(doc.text)
if self.min_tokens <= token_count <= self.max_tokens:
doc.extra_info = doc.extra_info or {}
+9 -17
View File
@@ -16,7 +16,7 @@ from typing import List
from application.parser.chunking import Chunker
from application.parser.chunking_creator import ChunkerCreator
from application.parser.schema.base import Document
from application.utils import get_encoding
from application.parser.tokenization import get_token_counter
logger = logging.getLogger(__name__)
@@ -41,19 +41,16 @@ class _BaseStrategyChunker:
self.max_tokens = max(1, int(max_tokens))
self.min_tokens = max(0, int(min_tokens))
self.duplicate_headers = duplicate_headers
self.encoding = get_encoding()
# Same unit as the embedding server counts in; see
# ``application.parser.tokenization``.
self.counter = get_token_counter()
def _token_count(self, text: str) -> int:
return len(self.encoding.encode_ordinary(text))
return self.counter.count(text)
def _split_by_tokens(self, text: str) -> List[str]:
"""Split ``text`` into pieces no larger than ``max_tokens`` tokens."""
tokens = self.encoding.encode_ordinary(text)
pieces = []
for start in range(0, len(tokens), self.max_tokens):
chunk_tokens = tokens[start:start + self.max_tokens]
pieces.append(self.encoding.decode(chunk_tokens))
return pieces
return self.counter.split(text, self.max_tokens)
def _emit(self, base: Document, part_index: int, text: str) -> Document:
"""Build a child Document carrying token_count and inherited info."""
@@ -188,16 +185,11 @@ class ParentChildChunker(_BaseStrategyChunker):
processed: List[Document] = []
child_size = self._child_size()
for doc in documents:
tokens = self.encoding.encode_ordinary(doc.text)
part_index = 0
for p_start in range(0, len(tokens), self.max_tokens):
parent_tokens = tokens[p_start:p_start + self.max_tokens]
parent_text = self.encoding.decode(parent_tokens)
for parent_text in self.counter.split(doc.text, self.max_tokens):
if not parent_text.strip():
continue
for c_start in range(0, len(parent_tokens), child_size):
child_tokens = parent_tokens[c_start:c_start + child_size]
child_text = self.encoding.decode(child_tokens)
for child_text in self.counter.split(parent_text, child_size):
if not child_text.strip():
continue
child = Document(
@@ -208,7 +200,7 @@ class ParentChildChunker(_BaseStrategyChunker):
embedding=doc.embedding,
extra_info={
**(doc.extra_info or {}),
"token_count": len(child_tokens),
"token_count": self.counter.count(child_text),
"parent_text": parent_text,
},
)
+217
View File
@@ -0,0 +1,217 @@
"""Counting and splitting text in the embedding model's own tokenizer.
Chunk sizes only mean something in the units the embedding server counts. The
chunker used to count cl100k (tiktoken) while the server counted whatever its
model used, so ``max_tokens`` was a value in one unit compared against a limit
in another. Every recalibration of that number was really an attempt to guess
the conversion factor.
This module removes the conversion. :func:`get_token_counter` returns a counter
backed by the configured embedding model's tokenizer, falling back to cl100k
when that tokenizer cannot be loaded -- an offline install, or a model the
registry does not describe.
Splitting never round-trips through ``decode``. Byte-level BPE decodes
losslessly, but WordPiece tokenizers do not: all-mpnet-base-v2 lowercases, so
decoding ``"Hello World"`` yields ``"hello world"`` and would silently rewrite
every stored document. Counters therefore cut the *original* string at
character offsets the tokenizer reports.
"""
from __future__ import annotations
import logging
import threading
from typing import Iterator, List, Optional, Tuple
from application.core.settings import settings
from application.utils import get_encoding
from application.vectorstore.model_registry import resolve
logger = logging.getLogger(__name__)
_cache: dict = {}
_cache_lock = threading.Lock()
def _windows(total: int, first: int, rest: int) -> Iterator[Tuple[int, int]]:
"""Yield ``(start, end)`` token windows, the first sized independently.
A header consumes part of the first chunk's budget but none of the rest,
so the first window is often smaller than those that follow.
"""
start = 0
budget = max(1, first)
while start < total:
end = min(start + budget, total)
yield start, end
start = end
budget = max(1, rest)
class TokenCounter:
"""Counts and splits text in one tokenizer's units.
Attributes:
name: Human-readable identifier of the underlying tokenizer, for logs.
"""
name = "unknown"
def count(self, text: str) -> int:
"""Number of tokens ``text`` occupies."""
raise NotImplementedError
def split(
self, text: str, max_tokens: int, first_max_tokens: Optional[int] = None
) -> List[str]:
"""Cut ``text`` into consecutive pieces of at most ``max_tokens``.
The concatenation of the returned pieces equals ``text`` exactly.
Args:
text: Source text.
max_tokens: Token budget per piece; values below 1 are treated as 1.
first_max_tokens: Budget for the first piece only, when it must
leave room for a header. Defaults to ``max_tokens``.
Returns:
The pieces, in order. An empty ``text`` yields an empty list.
"""
raise NotImplementedError
class TiktokenCounter(TokenCounter):
"""cl100k counter -- the historical behaviour, and the fallback."""
name = "cl100k_base"
def __init__(self) -> None:
self._encoding = get_encoding()
def count(self, text: str) -> int:
if not text:
return 0
return len(self._encoding.encode_ordinary(text))
def split(
self, text: str, max_tokens: int, first_max_tokens: Optional[int] = None
) -> List[str]:
if not text:
return []
rest = max(1, max_tokens)
first = max(1, first_max_tokens if first_max_tokens is not None else rest)
tokens = self._encoding.encode_ordinary(text)
if len(tokens) <= first:
return [text]
return [
self._encoding.decode(tokens[start:end])
for start, end in _windows(len(tokens), first, rest)
]
class HuggingFaceCounter(TokenCounter):
"""Counts in a Hugging Face tokenizer, slicing by character offsets."""
def __init__(self, tokenizer, name: str) -> None:
self._tokenizer = tokenizer
self.name = name
def _encode(self, text: str):
return self._tokenizer.encode(text, add_special_tokens=False)
def count(self, text: str) -> int:
if not text:
return 0
return len(self._encode(text).ids)
def split(
self, text: str, max_tokens: int, first_max_tokens: Optional[int] = None
) -> List[str]:
if not text:
return []
rest = max(1, max_tokens)
first = max(1, first_max_tokens if first_max_tokens is not None else rest)
offsets = self._encode(text).offsets
if len(offsets) <= first:
return [text]
pieces: List[str] = []
cursor = 0
for start_token, end_token in _windows(len(offsets), first, rest):
window = offsets[start_token:end_token]
# Some tokenizers emit (0, 0) for specials or normalised-away
# characters; those carry no span to cut on.
spans = [end for _, end in window if end > cursor]
if not spans:
continue
end_char = max(spans)
pieces.append(text[cursor:end_char])
cursor = end_char
if cursor < len(text):
# Trailing characters the tokenizer dropped (e.g. whitespace) belong
# to the final piece, so no input is lost.
if pieces:
pieces[-1] = pieces[-1] + text[cursor:]
else:
pieces.append(text[cursor:])
return pieces
def _load_hf_counter(repo: str) -> Optional[HuggingFaceCounter]:
"""Load ``repo``'s tokenizer, or ``None`` if it is not reachable."""
try:
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_pretrained(repo)
# Repos ship padding and truncation defaults meant for inference
# batches. Left on, every count returns the padded width (128 for
# mpnet) and the offsets carry (0, 0) entries for the padding, so both
# counting and slicing are wrong.
tokenizer.no_padding()
tokenizer.no_truncation()
return HuggingFaceCounter(tokenizer, repo)
except Exception as exc: # noqa: BLE001 -- chunking must never hard-fail here
logger.warning(
"Could not load the tokenizer for %s (%s); counting chunk sizes in "
"cl100k instead. Chunk sizes will be approximate for this model.",
repo,
exc,
)
return None
def get_token_counter(embeddings_name: Optional[str] = None) -> TokenCounter:
"""Return the counter for ``embeddings_name``, cached per process.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
Returns:
A :class:`HuggingFaceCounter` for a model whose tokenizer could be
loaded, else a :class:`TiktokenCounter`.
"""
name = embeddings_name or getattr(settings, "EMBEDDINGS_NAME", None)
key = name or "__default__"
with _cache_lock:
if key in _cache:
return _cache[key]
spec = resolve(name)
repo = spec.repo if spec else name
counter: TokenCounter
if repo and (spec is None or spec.provider == "fastembed"):
counter = _load_hf_counter(repo) or TiktokenCounter()
else:
counter = TiktokenCounter()
with _cache_lock:
_cache[key] = counter
logger.info("Chunking will count tokens with %s", counter.name)
return counter
def reset_cache() -> None:
"""Drop cached counters. For tests and for a settings change at runtime."""
with _cache_lock:
_cache.clear()
+3 -1
View File
@@ -20,6 +20,7 @@ ddgs>=8.0.0
fast-ebook
elevenlabs==2.62.0
Flask==3.1.3
fastembed==0.8.0
faiss-cpu==1.15.0
fastmcp==3.4.6
flask-restx==1.3.2
@@ -90,13 +91,14 @@ referencing>=0.28.0,<0.38.0
regex==2026.7.19
requests==2.34.2
retry==0.9.2
sentence-transformers==5.7.0
sqlalchemy>=2.0,<3
starlette>=1.0,<2
tiktoken==0.13.0
tokenizers==0.22.2
torch==2.11.0
tqdm==4.67.3
# torch and transformers are here for docling's model stack only -- embeddings
# moved to fastembed/onnxruntime. Both can go when docling does.
# Capped <5.9.0 to match docling-core's own darwin pin: 5.9+ breaks docling's
# PDF layout model on Apple Silicon ("Cannot convert a MPS Tensor to float64").
transformers==5.8.1
View File
Whitespace-only changes.
+89
View File
@@ -0,0 +1,89 @@
"""Download embedding model artifacts into FastEmbed's cache.
Run at image build time so a fresh container does not download a model on its
first ingest, and an air-gapped install works at all. Both the legacy and the
current default are baked: an upgraded deployment keeps using mpnet until it
runs ``reembed``, while a new one starts on granite.
Usage::
python -m application.scripts.prefetch_models # the defaults
python -m application.scripts.prefetch_models granite-311m # a subset
"""
from __future__ import annotations
import logging
import sys
from typing import List, Optional, Sequence
from application.vectorstore.model_registry import (
DEFAULT_LEGACY,
DEFAULT_NEW_INSTALL,
known_names,
resolve,
)
logger = logging.getLogger("prefetch_models")
#: Fetched when no names are given.
DEFAULT_MODELS = (DEFAULT_LEGACY, DEFAULT_NEW_INSTALL)
def prefetch(names: Sequence[str], cache_dir: Optional[str] = None) -> List[str]:
"""Fetch each named model's artifacts.
Args:
names: Registry names or aliases.
cache_dir: FastEmbed cache directory; its default when omitted.
Returns:
The repositories actually fetched.
Raises:
SystemExit: If a name is not in the registry, since a silent skip at
build time becomes a download at run time on an offline host.
"""
from fastembed import TextEmbedding
from fastembed.common.model_description import ModelSource, PoolingType
pooling_types = {"cls": PoolingType.CLS, "mean": PoolingType.MEAN}
fetched: List[str] = []
for name in names:
spec = resolve(name)
if spec is None:
raise SystemExit(
f"Unknown embedding model {name!r}. Known: {', '.join(known_names())}"
)
if spec.provider != "fastembed":
logger.info("Skipping %s: served remotely, nothing to cache.", spec.name)
continue
logger.info("Fetching %s", spec.repo)
TextEmbedding.add_custom_model(
model=spec.repo,
pooling=pooling_types[spec.pooling],
normalization=spec.normalize,
sources=ModelSource(hf=spec.repo),
dim=spec.dimension,
model_file=spec.onnx_file,
)
kwargs = {"model_name": spec.repo}
if cache_dir:
kwargs["cache_dir"] = cache_dir
TextEmbedding(**kwargs)
fetched.append(spec.repo)
return fetched
def main(argv: Optional[Sequence[str]] = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
import os
names = list(argv) if argv else list(DEFAULT_MODELS)
fetched = prefetch(names, os.environ.get("EMBEDDINGS_CACHE_DIR"))
logger.info("Cached %d model(s): %s", len(fetched), ", ".join(fetched))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+322
View File
@@ -0,0 +1,322 @@
"""Re-embed an existing index with the currently configured embedding model.
Changing ``EMBEDDINGS_NAME`` does not change vectors already stored. Because
several models share a width -- granite-311m and all-mpnet-base-v2 are both
768-dimensional -- a swapped model does not fail any dimension check. It simply
retrieves against vectors that mean something else, which shows up as bad
answers rather than as an error.
This script rebuilds the vectors from the chunk text already held in the store,
so it never re-downloads, re-parses or re-chunks anything: no source files, no
crawl, no docling. It works against pgvector and FAISS.
Usage::
python -m application.scripts.reembed --dry-run # report, change nothing
python -m application.scripts.reembed # re-embed everything
python -m application.scripts.reembed --sources a,b # only these sources
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
from application.core.settings import settings
from application.vectorstore.model_registry import resolve
from application.vectorstore.vector_creator import VectorCreator
logger = logging.getLogger("reembed")
#: Stores this script knows how to rewrite in place.
SUPPORTED_STORES = ("pgvector", "faiss")
#: Chunks embedded (and, for pgvector, written) per transaction.
DEFAULT_BATCH_SIZE = 64
@dataclass
class _RebuildDoc:
"""Minimal document shape ``FaissStore(docs_init=...)`` accepts."""
page_content: str
metadata: Dict[str, Any]
class ReembedError(RuntimeError):
"""Raised for a misconfiguration the user needs to fix before running."""
def _log_setup(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
def _batched(items: Sequence[Any], size: int) -> Iterable[Sequence[Any]]:
for start in range(0, len(items), size):
yield items[start : start + size]
def list_source_ids(store_type: str) -> List[str]:
"""Every source id that has vectors in the configured store.
Args:
store_type: ``"pgvector"`` or ``"faiss"``.
Returns:
Source ids, sorted for a stable and resumable ordering.
"""
if store_type == "pgvector":
return _pgvector_source_ids()
return _faiss_source_ids()
def _pgvector_source_ids() -> List[str]:
store = VectorCreator.create_vectorstore("pgvector", source_id="")
# ``_get_connection`` hands back a pooled connection, not a context
# manager: closing it via ``with`` would cost the pool a slot.
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
f"SELECT DISTINCT source_id FROM {store._table_name} "
f"WHERE source_id IS NOT NULL ORDER BY source_id"
)
return [row[0] for row in cursor.fetchall()]
finally:
cursor.close()
store.close()
def _faiss_source_ids() -> List[str]:
"""Directories under the FAISS root that hold an index."""
from application.storage.storage_creator import StorageCreator
storage = StorageCreator.get_storage()
root = "indexes"
ids: List[str] = []
try:
for path in storage.list_files(root):
# ``indexes/<source_id>/index.faiss``
parts = str(path).split("/")
if parts and parts[-1].endswith(".faiss") and len(parts) >= 2:
ids.append(parts[-2])
except Exception as exc: # noqa: BLE001 -- surfaced to the user below
raise ReembedError(
f"Could not list FAISS indexes under {root!r}: {exc}"
) from exc
return sorted(set(ids))
def reembed_pgvector(source_id: str, batch_size: int, dry_run: bool) -> Tuple[int, int]:
"""Rewrite one source's vectors in place.
Rows are updated, never deleted and re-inserted, so an interrupted run
leaves every chunk present and simply re-does its batch next time.
Args:
source_id: Source whose chunks to re-embed.
batch_size: Chunks per embed call and per transaction.
dry_run: When true, count the work and change nothing.
Returns:
``(chunks_seen, chunks_written)``.
"""
store = VectorCreator.create_vectorstore("pgvector", source_id=source_id)
table, vector_column = store._table_name, store._vector_column
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
f"SELECT id, text FROM {table} WHERE source_id = %s ORDER BY id",
(source_id,),
)
rows = cursor.fetchall()
finally:
cursor.close()
if dry_run or not rows:
store.close()
return len(rows), 0
written = 0
try:
for batch in _batched(rows, batch_size):
vectors = store._embedding.embed_documents([row[1] or "" for row in batch])
cursor = conn.cursor()
try:
cursor.executemany(
f"UPDATE {table} SET {vector_column} = %s::vector WHERE id = %s",
[
(str(list(vector)), row[0])
for vector, row in zip(vectors, batch)
],
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
written += len(batch)
finally:
store.close()
return len(rows), written
def reembed_faiss(source_id: str, batch_size: int, dry_run: bool) -> Tuple[int, int]:
"""Rebuild one FAISS index from the chunk text in its sidecar.
A FAISS index is a flat array, so this rebuilds rather than updates. Every
new vector is computed *before* the old index is touched, so an interrupt
during embedding leaves the existing index intact.
Args:
source_id: Source whose index to rebuild.
batch_size: Chunks per embed call.
dry_run: When true, count the work and change nothing.
Returns:
``(chunks_seen, chunks_written)``.
"""
store = VectorCreator.create_vectorstore(
"faiss", source_id=source_id, embeddings_key="embeddings"
)
chunks: List[Dict[str, Any]] = store.get_chunks() or []
if dry_run or not chunks:
return len(chunks), 0
docs = [
_RebuildDoc(chunk.get("text") or "", chunk.get("metadata") or {})
for chunk in chunks
]
# Constructing with ``docs_init`` embeds everything into a fresh in-memory
# index and touches nothing on disk. The existing index is only replaced by
# ``save_local`` below, so a failure while embedding leaves it intact.
rebuilt = VectorCreator.create_vectorstore(
"faiss", source_id=source_id, embeddings_key="embeddings", docs_init=docs
)
rebuilt.save_local()
return len(chunks), len(docs)
def run(
store_type: str,
source_ids: Optional[Sequence[str]],
batch_size: int,
dry_run: bool,
) -> int:
"""Re-embed the requested sources. Returns a process exit code."""
model = settings.EMBEDDINGS_NAME
spec = resolve(model)
logger.info(
"Re-embedding %s with %s (%s)",
store_type,
model,
f"{spec.dimension} dims" if spec else "width determined at runtime",
)
if dry_run:
logger.info("Dry run: nothing will be written.")
ids = list(source_ids) if source_ids else list_source_ids(store_type)
if not ids:
logger.warning("No sources found; nothing to do.")
return 0
logger.info("%d source(s) to process", len(ids))
handler = reembed_pgvector if store_type == "pgvector" else reembed_faiss
total_seen = total_written = 0
failures: List[str] = []
started = time.monotonic()
for position, source_id in enumerate(ids, start=1):
try:
seen, written = handler(source_id, batch_size, dry_run)
total_seen += seen
total_written += written
logger.info(
"[%d/%d] %s: %d chunk(s)%s",
position,
len(ids),
source_id,
seen,
"" if dry_run else f", {written} re-embedded",
)
except Exception as exc: # noqa: BLE001 -- one bad source must not stop the run
failures.append(source_id)
logger.error("[%d/%d] %s FAILED: %s", position, len(ids), source_id, exc)
elapsed = time.monotonic() - started
logger.info(
"Done in %.1fs: %d chunk(s) seen, %d re-embedded, %d source(s) failed",
elapsed,
total_seen,
total_written,
len(failures),
)
if failures:
logger.error("Failed sources: %s", ", ".join(failures))
logger.error("Re-run with --sources %s to retry just those.", ",".join(failures))
return 1
if dry_run:
logger.info("Re-run without --dry-run to apply.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m application.scripts.reembed",
description=__doc__.split("\n\n")[0],
)
parser.add_argument(
"--dry-run", action="store_true", help="report what would change, write nothing"
)
parser.add_argument(
"--sources", help="comma-separated source ids; default is every source"
)
parser.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"chunks per embed call (default {DEFAULT_BATCH_SIZE})",
)
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
args = build_parser().parse_args(argv)
_log_setup(args.verbose)
store_type = (settings.VECTOR_STORE or "").lower()
if store_type not in SUPPORTED_STORES:
logger.error(
"VECTOR_STORE is %r; this script supports %s. For other stores, "
"delete and re-ingest the affected sources.",
settings.VECTOR_STORE,
" and ".join(SUPPORTED_STORES),
)
return 2
sources = (
[s.strip() for s in args.sources.split(",") if s.strip()]
if args.sources
else None
)
try:
return run(store_type, sources, max(1, args.batch_size), args.dry_run)
except ReembedError as exc:
logger.error("%s", exc)
return 2
if __name__ == "__main__":
sys.exit(main())
+77 -76
View File
@@ -1,5 +1,4 @@
import logging
import os
from abc import ABC, abstractmethod
from typing import Optional
@@ -7,7 +6,11 @@ import requests
from application.core.settings import settings
from application.vectorstore.embeddings_openai import OpenAIEmbeddings
from application.utils import get_encoding
from application.vectorstore.model_registry import (
dimension_for,
max_input_tokens_for,
resolve,
)
class RemoteEmbeddings:
@@ -23,45 +26,75 @@ class RemoteEmbeddings:
self.headers = {"Content-Type": "application/json"}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
self.dimension = 768
# Width comes from the registry. This used to be a hardcoded 768 that
# ``embed_query`` claimed to correct on first use -- but the correction
# was guarded by ``if self.dimension is None``, which the hardcode made
# unreachable, so a remote model of any other width silently produced a
# ``vector(768)`` column. ``None`` here means "unknown", and the probe
# below now genuinely runs.
self.dimension = dimension_for(model_name)
def _token_counter(self):
"""Counter matching the remote model's tokenizer, cached per process."""
from application.parser.tokenization import get_token_counter
return get_token_counter(self.model_name)
def _resolve_input_limit(self):
"""Token ceiling for a single embed input, or ``None`` for no limit.
``EMBEDDINGS_MAX_INPUT_TOKENS`` wins when set. Otherwise a registered
model contributes its own context window, so a request that the server
would reject -- or silently truncate -- is clipped here instead of
being sent and paid for.
"""
configured = settings.EMBEDDINGS_MAX_INPUT_TOKENS
if configured and configured > 0:
return configured
model_limit = max_input_tokens_for(self.model_name)
return model_limit if model_limit and model_limit > 0 else None
def _truncate_inputs(self, inputs):
"""Clip each input to ``EMBEDDINGS_MAX_INPUT_TOKENS`` tokens.
"""Clip each input to the resolved token limit.
The remote server (e.g. llama.cpp) hard-rejects any single input
larger than its physical batch size with a 500. When the setting is
configured, each input is truncated to that many tokens before the
request and the overflow is dropped (lossy by design). Token counts
use the shared tiktoken encoding, which differs from the server's
tokenizer, so set the limit with headroom under the server's true
limit to absorb tokenizer skew.
larger than its physical batch size with a 500, so oversized inputs are
truncated before the request and the overflow is dropped (lossy by
design).
Counting uses the embedding model's own tokenizer where it is known, so
the limit and the count are in the same unit. When it is not -- an
unregistered model, or no tokenizer available -- this falls back to
tiktoken, and the limit should then carry headroom to absorb the skew
between the two tokenizers.
Args:
inputs: A single string or a list of strings to embed.
Returns:
The inputs with each string clipped to the token limit, or the
inputs unchanged when the limit is unset or non-positive.
inputs unchanged when no limit applies.
"""
limit = settings.EMBEDDINGS_MAX_INPUT_TOKENS
if not limit or limit <= 0:
limit = self._resolve_input_limit()
if not limit:
return inputs
encoding = get_encoding()
counter = self._token_counter()
def clip(text):
if not isinstance(text, str):
return text
tokens = encoding.encode_ordinary(text)
if len(tokens) <= limit:
count = counter.count(text)
if count <= limit:
return text
logging.warning(
"Truncating remote embeddings input from %d to %d tokens (%d dropped)",
len(tokens),
count,
limit,
len(tokens) - limit,
count - limit,
)
return encoding.decode(tokens[:limit])
pieces = counter.split(text, limit)
return pieces[0] if pieces else text
if isinstance(inputs, list):
return [clip(text) for text in inputs]
@@ -129,7 +162,7 @@ class RemoteEmbeddings:
def _get_embeddings_wrapper():
"""Lazy import of EmbeddingsWrapper to avoid loading SentenceTransformer when using remote embeddings."""
"""Lazy import of EmbeddingsWrapper, so a remote setup never loads ONNX."""
from application.vectorstore.embeddings_local import EmbeddingsWrapper
return EmbeddingsWrapper
@@ -178,36 +211,28 @@ class EmbeddingsSingleton:
@staticmethod
def _create_instance(embeddings_name, *args, **kwargs):
if embeddings_name == "openai_text-embedding-ada-002":
"""Build the runner for ``embeddings_name``, per the model registry.
The registry replaced a hand-maintained factory dict whose entries
existed only to rewrite a configured name into a repository id. That
rewrite is now a registry field, so an unknown name needs no entry
here: it is passed through as a Hugging Face repository.
"""
spec = resolve(embeddings_name)
if spec is not None and spec.provider == "openai":
return OpenAIEmbeddings(*args, **kwargs)
# Lazy import EmbeddingsWrapper only when needed (avoids loading SentenceTransformer)
EmbeddingsWrapper = _get_embeddings_wrapper()
embeddings_factory = {
"huggingface_sentence-transformers/all-mpnet-base-v2": lambda: EmbeddingsWrapper(
"sentence-transformers/all-mpnet-base-v2"
),
"huggingface_sentence-transformers-all-mpnet-base-v2": lambda: EmbeddingsWrapper(
"sentence-transformers/all-mpnet-base-v2"
),
"huggingface_hkunlp/instructor-large": lambda: EmbeddingsWrapper(
"hkunlp/instructor-large"
),
}
if embeddings_name in embeddings_factory:
if args or kwargs:
logging.debug(
"Dropping %d positional and %d keyword argument(s) for pinned "
"embeddings model %s: its factory takes none.",
len(args),
len(kwargs),
embeddings_name,
)
return embeddings_factory[embeddings_name]()
else:
return EmbeddingsWrapper(embeddings_name, *args, **kwargs)
if spec is not None and (args or kwargs):
logging.debug(
"Dropping %d positional and %d keyword argument(s) for registered "
"embeddings model %s: the registry supplies its configuration.",
len(args),
len(kwargs),
embeddings_name,
)
return EmbeddingsWrapper(embeddings_name)
return EmbeddingsWrapper(embeddings_name, *args, **kwargs)
def _azure_configured() -> bool:
@@ -225,11 +250,8 @@ def get_embeddings(
"""Resolve the configured embeddings instance. The single entry point.
Callers that reach for :meth:`EmbeddingsSingleton.get_instance` directly
reproduce neither the bundled local-model path (the Docker image ships
``/app/models/all-mpnet-base-v2``) nor the OpenAI/Azure key handling: they
download a second copy of the model from the hub, and passing the key
positionally to the pinned HuggingFace names raises ``TypeError`` because
those factories take no arguments. Route every caller through here.
skip the remote dispatch and the OpenAI/Azure key handling. Route every
caller through here.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
@@ -259,31 +281,10 @@ def get_embeddings(
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name, openai_api_key=embeddings_key
)
elif embeddings_name == "huggingface_sentence-transformers/all-mpnet-base-v2":
possible_paths = [
"/app/models/all-mpnet-base-v2", # Docker absolute path
"./models/all-mpnet-base-v2", # Relative path
]
local_model_path = None
for path in possible_paths:
if os.path.exists(path):
local_model_path = path
logging.info(f"Found local model at path: {path}")
break
else:
logging.info(f"Path does not exist: {path}")
if local_model_path:
embedding_instance = EmbeddingsSingleton.get_instance(
local_model_path,
)
else:
logging.warning(
f"Local model not found in any of the paths: {possible_paths}. Falling back to HuggingFace download."
)
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name,
)
else:
# No per-model branching: the registry resolves names and FastEmbed
# caches artifacts under EMBEDDINGS_CACHE_DIR, which is where the
# image warms them at build time.
embedding_instance = EmbeddingsSingleton.get_instance(embeddings_name)
return embedding_instance
+173 -38
View File
@@ -1,54 +1,189 @@
"""
Local embeddings using SentenceTransformer.
This module is only imported when EMBEDDINGS_BASE_URL is not set,
to avoid loading SentenceTransformer into memory when using remote embeddings.
"""Local embeddings via FastEmbed (ONNX Runtime).
Replaces the previous SentenceTransformer implementation. Both run the same
weights; FastEmbed reaches them through ONNX Runtime instead of torch, which
removes torch, transformers and sentence-transformers from the dependency set
and measurably reduces both resident memory and import cost.
The swap is numerically transparent for existing indexes: embedding the same
text with SentenceTransformer and with FastEmbed's ONNX graph of the same
model returns vectors at cosine 1.0, so an mpnet index built before this
change keeps working unchanged.
Models are described in :mod:`application.vectorstore.model_registry`. A name
the registry does not know is treated as a Hugging Face repository, which is
what someone configuring an arbitrary model expects.
"""
import logging
import threading
from typing import Any, List, Optional
from sentence_transformers import SentenceTransformer
from application.core.settings import settings
from application.vectorstore.model_registry import EmbeddingModel, resolve, known_names
logger = logging.getLogger(__name__)
# ``add_custom_model`` mutates a process-global registry inside FastEmbed, so
# repeated registration of the same name is both wasteful and racy under the
# thread pool the API serves requests from.
_registered: set = set()
_register_lock = threading.Lock()
# Assumed layout for a model the registry does not describe.
_FALLBACK_ONNX_FILE = "onnx/model.onnx"
_FALLBACK_POOLING = "mean"
def _pooling_type(pooling: str):
"""Map our ``"cls"``/``"mean"`` spelling onto FastEmbed's enum."""
from fastembed.common.model_description import PoolingType
return {"cls": PoolingType.CLS, "mean": PoolingType.MEAN}[pooling]
def _register(model: EmbeddingModel) -> None:
"""Teach FastEmbed about a model, exactly once per process."""
from fastembed import TextEmbedding
from fastembed.common.model_description import ModelSource
with _register_lock:
if model.repo in _registered:
return
TextEmbedding.add_custom_model(
model=model.repo,
pooling=_pooling_type(model.pooling),
normalization=model.normalize,
sources=ModelSource(hf=model.repo),
dim=model.dimension,
model_file=model.onnx_file,
)
_registered.add(model.repo)
def _spec_for(model_name: str) -> EmbeddingModel:
"""Registry entry for ``model_name``, or a best-effort one for a raw repo.
Args:
model_name: Configured ``EMBEDDINGS_NAME``.
Returns:
A registry entry. For an unregistered name the returned entry assumes
the standard Hugging Face ONNX layout and mean pooling, and carries
``dimension = 0`` so the caller knows to probe for the real width.
"""
spec = resolve(model_name)
if spec is not None:
return spec
logger.info(
"Embedding model %r is not in the registry (known: %s); treating it as a "
"Hugging Face repository with %s and %s pooling.",
model_name,
", ".join(known_names()),
_FALLBACK_ONNX_FILE,
_FALLBACK_POOLING,
)
return EmbeddingModel(
name=model_name,
dimension=0,
max_input_tokens=512,
pooling=_FALLBACK_POOLING,
normalize=True,
repo=model_name,
onnx_file=_FALLBACK_ONNX_FILE,
)
class EmbeddingsWrapper:
def __init__(self, model_name, *args, **kwargs):
logging.info(f"Initializing EmbeddingsWrapper with model: {model_name}")
"""Runs an embedding model locally through FastEmbed.
Exposes the ``embed_query``/``embed_documents``/``dimension`` interface the
vector stores rely on, matching ``RemoteEmbeddings`` and ``OpenAIEmbeddings``.
"""
def __init__(self, model_name: str, *args: Any, **kwargs: Any) -> None:
"""Load ``model_name`` locally.
Args:
model_name: Registry name, alias, or a Hugging Face repository id.
Raises:
RuntimeError: If the model cannot be loaded, with the configured
name and the registry's known names in the message.
"""
from fastembed import TextEmbedding
self.spec = _spec_for(model_name)
logger.info("Loading embeddings model %s via FastEmbed", self.spec.repo)
try:
kwargs.setdefault("trust_remote_code", True)
self.model = SentenceTransformer(
model_name,
config_kwargs={"allow_dangerous_deserialization": True},
*args,
**kwargs,
)
if self.model is None or self.model._first_module() is None:
raise ValueError(
f"SentenceTransformer model failed to load properly for: {model_name}"
)
# Renamed in sentence-transformers 5.4; keep the old name as a fallback.
get_dimension = getattr(
self.model,
"get_embedding_dimension",
getattr(self.model, "get_sentence_embedding_dimension", None),
)
self.dimension = get_dimension()
logging.info(f"Successfully loaded model with dimension: {self.dimension}")
except Exception as e:
logging.error(
f"Failed to initialize SentenceTransformer with model {model_name}: {str(e)}",
exc_info=True,
)
raise
_register(self.spec)
init_kwargs = {"model_name": self.spec.repo}
threads = getattr(settings, "EMBEDDINGS_THREADS", None)
if isinstance(threads, int) and threads > 0:
init_kwargs["threads"] = threads
cache_dir = getattr(settings, "EMBEDDINGS_CACHE_DIR", None)
if cache_dir:
init_kwargs["cache_dir"] = cache_dir
self.model = TextEmbedding(**init_kwargs)
except Exception as exc:
raise RuntimeError(
f"Could not load embeddings model {model_name!r} via FastEmbed: "
f"{exc}. Known models: {', '.join(known_names())}."
) from exc
def embed_query(self, query: str):
return self.model.encode(query).tolist()
self.dimension = self.spec.dimension or self._probe_dimension()
logger.info("Embeddings model ready (dimension=%d)", self.dimension)
def embed_documents(self, documents: list):
return self.model.encode(documents).tolist()
def _probe_dimension(self) -> int:
"""Determine the vector width of a model the registry does not describe."""
return len(self.embed_query("dimension probe"))
@property
def tokenizer(self):
"""The model's own tokenizer, so chunking can count in its units."""
return self.model.model.tokenizer
def embed_query(self, query: str) -> List[float]:
"""Embed a single query string."""
return self.embed_documents([query])[0]
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of documents, preserving input order.
Inputs are grouped by length before batching. ONNX needs a rectangular
tensor, so every input in a batch is padded up to the longest one in
it; with mixed lengths that padding is most of the work. Grouping
similar lengths together measured 19% faster and 45% lower peak memory
on production-sized chunks, and at full context an unsorted batch of 4
was slower than no batching at all.
The original order is restored before returning, so callers zipping
these against their texts are unaffected.
"""
if not documents:
return []
batch_size: Optional[int] = None
raw = getattr(settings, "EMBEDDINGS_BATCH_SIZE", None)
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
batch_size = raw
documents = list(documents)
if batch_size is None or len(documents) <= batch_size:
# One batch either way: sorting would only add work.
return [v.tolist() for v in self.model.embed(documents, batch_size=batch_size)]
order = sorted(range(len(documents)), key=lambda i: len(documents[i]))
grouped = [documents[i] for i in order]
vectors = [v.tolist() for v in self.model.embed(grouped, batch_size=batch_size)]
restored: List[Optional[List[float]]] = [None] * len(documents)
for position, original_index in enumerate(order):
restored[original_index] = vectors[position]
return restored
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
else:
raise ValueError("Input must be a string or a list of strings")
raise ValueError("Input must be a string or a list of strings")
+23 -18
View File
@@ -301,24 +301,29 @@ class FaissStore(BaseVectorStore):
# -- Introspection ---------------------------------------------------
def assert_embedding_dimensions(self, embeddings) -> None:
"""Check the index width matches the embedding model's width."""
if (
settings.EMBEDDINGS_NAME
== "huggingface_sentence-transformers/all-mpnet-base-v2"
):
word_embedding_dimension = getattr(embeddings, "dimension", None)
if word_embedding_dimension is None:
raise AttributeError(
"'dimension' attribute not found in embeddings instance."
)
if self.index is None:
return
if word_embedding_dimension != self.index.d:
raise ValueError(
f"Embedding dimension mismatch: embeddings.dimension "
f"({word_embedding_dimension}) != docsearch index dimension "
f"({self.index.d})"
)
"""Check the index width matches the embedding model's width.
This used to run only when ``EMBEDDINGS_NAME`` was mpnet, so every
other model skipped the check entirely -- exactly the models most
likely to differ from an index built earlier. It now runs for any
model that reports a width.
"""
word_embedding_dimension = getattr(embeddings, "dimension", None)
if word_embedding_dimension is None:
# A remote model of unknown width reports None until its first
# call; there is nothing to compare yet.
return
if self.index is None:
return
if word_embedding_dimension != self.index.d:
raise ValueError(
f"Embedding dimension mismatch: {settings.EMBEDDINGS_NAME} produces "
f"{word_embedding_dimension}-dim vectors but this FAISS index is "
f"{self.index.d}-dim. The index was built with a different "
f"embedding model; re-embed it with "
f"`python -m application.scripts.reembed` or point "
f"EMBEDDINGS_NAME back at the original model."
)
def get_chunks(self) -> List[Dict[str, Any]]:
"""Return every chunk held in the index."""
+170
View File
@@ -0,0 +1,170 @@
"""Canonical description of every embedding model DocsGPT knows how to run.
``EMBEDDINGS_NAME`` used to be a free-form string interpreted in half a dozen
places: a factory dict here, a bundled-model path probe there, a dimension
assertion gated on one model's name, and a hardcoded ``dimension = 768`` on the
remote client. Each of those encoded a different subset of the same facts, and
they drifted.
This module is the single place those facts live. A model is described once and
every consumer -- the local runner, the remote client, the schema bootstrap, the
chunker -- reads the same entry.
Unknown names are not an error: :func:`resolve` returns ``None`` and callers
fall back to treating the name as a Hugging Face repository, which is what a
user configuring an arbitrary model expects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple
@dataclass(frozen=True)
class EmbeddingModel:
"""Everything the application needs to know about one embedding model.
Attributes:
name: Canonical ``EMBEDDINGS_NAME`` value.
dimension: Width of the vectors it produces.
max_input_tokens: The model's own context window, counted in *its*
tokenizer. Used to bound what we send it, never to silently
reshape chunks.
pooling: ``"cls"`` or ``"mean"`` -- how token vectors become one vector.
normalize: Whether outputs are L2-normalised to unit length.
provider: Which runner handles it (``"fastembed"`` or ``"openai"``).
repo: Hugging Face repository holding weights and tokenizer.
onnx_file: Path within ``repo`` to the ONNX graph to run.
aliases: Other spellings of ``name`` accepted from configuration.
"""
name: str
dimension: int
max_input_tokens: int
pooling: str = "mean"
normalize: bool = True
provider: str = "fastembed"
repo: Optional[str] = None
onnx_file: Optional[str] = None
aliases: Tuple[str, ...] = field(default_factory=tuple)
#: The model DocsGPT installed before the granite migration. Kept as the
#: default so an existing deployment that upgrades keeps its index working;
#: new installs are pointed at granite by the setup script and env template.
MPNET = EmbeddingModel(
name="huggingface_sentence-transformers/all-mpnet-base-v2",
dimension=768,
max_input_tokens=384,
pooling="mean",
normalize=True,
repo="sentence-transformers/all-mpnet-base-v2",
onnx_file="onnx/model.onnx",
aliases=(
"huggingface_sentence-transformers-all-mpnet-base-v2",
"sentence-transformers/all-mpnet-base-v2",
"all-mpnet-base-v2",
),
)
#: Default for new installs: same 768 dimensions as mpnet, an 8x wider
#: effective input, and multilingual retrieval.
GRANITE_311M = EmbeddingModel(
name="ibm-granite/granite-embedding-311m-multilingual-r2",
dimension=768,
max_input_tokens=32768,
pooling="cls",
normalize=True,
repo="ibm-granite/granite-embedding-311m-multilingual-r2",
onnx_file="onnx/model_quint8_avx2.onnx",
aliases=("granite-embedding-311m-multilingual-r2", "granite-311m"),
)
#: Smaller granite. Half the vector width, roughly three times the speed.
GRANITE_97M = EmbeddingModel(
name="ibm-granite/granite-embedding-97m-multilingual-r2",
dimension=384,
max_input_tokens=32768,
pooling="cls",
normalize=True,
repo="ibm-granite/granite-embedding-97m-multilingual-r2",
onnx_file="onnx/model_quint8_avx2.onnx",
aliases=("granite-embedding-97m-multilingual-r2", "granite-97m"),
)
OPENAI_ADA_002 = EmbeddingModel(
name="openai_text-embedding-ada-002",
dimension=1536,
max_input_tokens=8191,
pooling="mean",
normalize=True,
provider="openai",
repo=None,
onnx_file=None,
aliases=("text-embedding-ada-002",),
)
MODELS: Tuple[EmbeddingModel, ...] = (
MPNET,
GRANITE_311M,
GRANITE_97M,
OPENAI_ADA_002,
)
#: Fallback vector width when the configured model is unknown -- the width
#: every DocsGPT install has used to date, so an unrecognised model does not
#: silently reshape an existing table.
DEFAULT_EMBEDDING_DIMENSION = 768
#: Name that a fresh install should be configured with.
DEFAULT_NEW_INSTALL = GRANITE_311M.name
#: Name that ``settings.EMBEDDINGS_NAME`` defaults to, i.e. what an existing
#: deployment falls back to when it never pinned one.
DEFAULT_LEGACY = MPNET.name
def _index() -> Dict[str, EmbeddingModel]:
"""Build the lookup table of every accepted spelling."""
table: Dict[str, EmbeddingModel] = {}
for model in MODELS:
for key in (model.name, *model.aliases):
table[key.lower()] = model
return table
_LOOKUP = _index()
def resolve(name: Optional[str]) -> Optional[EmbeddingModel]:
"""Return the registry entry for ``name``, or ``None`` when unknown.
Args:
name: A configured ``EMBEDDINGS_NAME`` value, in any accepted spelling.
Returns:
The matching :class:`EmbeddingModel`, or ``None`` for a name the
registry does not describe -- which callers treat as a Hugging Face
repository rather than an error.
"""
if not name:
return None
return _LOOKUP.get(name.strip().lower())
def dimension_for(name: Optional[str]) -> Optional[int]:
"""Vector width for ``name``, or ``None`` when unknown."""
model = resolve(name)
return model.dimension if model else None
def max_input_tokens_for(name: Optional[str]) -> Optional[int]:
"""Context window for ``name``, or ``None`` when unknown."""
model = resolve(name)
return model.max_input_tokens if model else None
def known_names() -> Tuple[str, ...]:
"""Canonical names of every registered model, for error messages."""
return tuple(model.name for model in MODELS)
+39
View File
@@ -11,6 +11,45 @@ import { Callout } from 'nextra/components'
**Upgrading from 0.16.x?** User data moved from MongoDB to Postgres in 0.17.0. Follow the [Postgres Migration guide](/Deploying/Postgres-Migration) before running `docker compose pull` or `git pull` — existing deployments will not start cleanly without it.
</Callout>
## Embedding models
DocsGPT now runs embeddings through [FastEmbed](https://github.com/qdrant/fastembed) (ONNX Runtime) instead of SentenceTransformer. The models are the same and the vectors are identical, so **an existing deployment needs no action** — `all-mpnet-base-v2` keeps working exactly as before.
New installs default to `ibm-granite/granite-embedding-311m-multilingual-r2`: multilingual, a 32k-token context, and the same 768 dimensions.
### Switching an existing deployment to granite
<Callout type="error">
Changing `EMBEDDINGS_NAME` on an index that already has vectors **breaks retrieval silently**. Both models are 768-dimensional, so nothing raises an error — queries are simply compared against vectors that mean something else, and answers quietly get worse. Always re-embed.
</Callout>
Set the model, then rebuild the vectors:
```bash
# 1. In your .env
EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2
# 2. Rebuild the vectors from the chunk text already in your index
docker compose exec backend python -m application.scripts.reembed --dry-run
docker compose exec backend python -m application.scripts.reembed
```
Re-embedding reads the chunk text already stored in your index. It does not re-download, re-parse or re-chunk your documents, so no source files are needed and the run is proportional to index size, not corpus size. Both `pgvector` and `faiss` are supported.
Useful flags:
| Flag | Effect |
| --- | --- |
| `--dry-run` | Report how many chunks would change, write nothing |
| `--sources a,b` | Only these source ids — also how you retry a failed source |
| `--batch-size N` | Chunks per embed call (default 64) |
The script processes sources independently: one failing source is reported and skipped rather than aborting the run, and the exit code is non-zero if any failed. For `pgvector` it updates rows in place, so an interrupted run simply re-does its last batch. For `faiss` it builds the replacement index in memory and only writes once the whole rebuild succeeds, leaving the existing index untouched on failure.
<Callout type="info">
Staying on `all-mpnet-base-v2` is a supported choice — it remains in the model registry and in `setup.sh`. You only need this section if you want to move to granite.
</Callout>
## Check your version
```bash
+11 -6
View File
@@ -250,17 +250,18 @@ configure_vector_store() {
configure_embeddings() {
echo -e "\n${DEFAULT_FG}${BOLD}Embeddings Configuration${NC}"
echo -e "${DEFAULT_FG}Choose your embeddings provider:${NC}"
echo -e "${YELLOW}1) HuggingFace (default, local)${NC}"
echo -e "${YELLOW}1) Granite multilingual (default, local)${NC}"
echo -e "${YELLOW}2) OpenAI Embeddings${NC}"
echo -e "${YELLOW}3) Custom Remote Embeddings (OpenAI-compatible API)${NC}"
echo -e "${YELLOW}4) all-mpnet-base-v2 (legacy local, English-only)${NC}"
echo -e "${YELLOW}b) Back${NC}"
echo
read -p "$(echo -e "${DEFAULT_FG}Choose option (1-3, or b): ${NC}")" emb_choice
read -p "$(echo -e "${DEFAULT_FG}Choose option (1-4, or b): ${NC}")" emb_choice
case "$emb_choice" in
1)
echo "EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" >> "$ENV_FILE"
echo -e "${GREEN}Embeddings set to HuggingFace (local).${NC}"
echo "EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2" >> "$ENV_FILE"
echo -e "${GREEN}Embeddings set to granite-311m-multilingual-r2 (local).${NC}"
;;
2)
echo "EMBEDDINGS_NAME=openai_text-embedding-ada-002" >> "$ENV_FILE"
@@ -277,6 +278,10 @@ configure_embeddings() {
[ -n "$emb_key" ] && echo "EMBEDDINGS_KEY=$emb_key" >> "$ENV_FILE"
echo -e "${GREEN}Custom remote embeddings configured.${NC}"
;;
4)
echo "EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" >> "$ENV_FILE"
echo -e "${GREEN}Embeddings set to all-mpnet-base-v2 (legacy local).${NC}"
;;
b|B) return ;;
*) echo -e "\n${RED}Invalid choice.${NC}" ; sleep 1 ;;
esac
@@ -515,7 +520,7 @@ serve_local_ollama() {
echo "LLM_NAME=$model_name" >> "$ENV_FILE"
echo "VITE_API_STREAMING=true" >> "$ENV_FILE"
echo "OPENAI_BASE_URL=http://ollama:11434/v1" >> "$ENV_FILE"
echo "EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" >> "$ENV_FILE"
echo "EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2" >> "$ENV_FILE"
echo -e "${GREEN}.env file configured for Ollama ($(echo "$docker_compose_file_suffix" | tr '[:lower:]' '[:upper:]')${NC}${GREEN}).${NC}"
prompt_advanced_settings
@@ -628,7 +633,7 @@ connect_local_inference_engine() {
echo "LLM_NAME=$model_name" >> "$ENV_FILE"
echo "VITE_API_STREAMING=true" >> "$ENV_FILE"
echo "OPENAI_BASE_URL=$openai_base_url" >> "$ENV_FILE"
echo "EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" >> "$ENV_FILE"
echo "EMBEDDINGS_NAME=ibm-granite/granite-embedding-311m-multilingual-r2" >> "$ENV_FILE"
echo -e "${GREEN}.env file configured for ${BOLD}${engine_name}${NC}${GREEN} with OpenAI API format.${NC}"
echo -e "${YELLOW}Note: MODEL_NAME is set to '${BOLD}$model_name${NC}${YELLOW}'. You can change it later in the .env file.${NC}"
+23
View File
@@ -0,0 +1,23 @@
import pytest
@pytest.fixture(scope="session")
def _mpnet_tokenizer():
"""Real WordPiece tokenizer; skipped when the hub is unreachable."""
pytest.importorskip("tokenizers")
from tokenizers import Tokenizer
try:
tokenizer = Tokenizer.from_pretrained("sentence-transformers/all-mpnet-base-v2")
except Exception as exc: # offline CI
pytest.skip(f"tokenizer unavailable: {exc}")
tokenizer.no_padding()
tokenizer.no_truncation()
return tokenizer
@pytest.fixture
def hf_counter(_mpnet_tokenizer):
from application.parser.tokenization import HuggingFaceCounter
return HuggingFaceCounter(_mpnet_tokenizer, "sentence-transformers/all-mpnet-base-v2")
+9 -3
View File
@@ -108,18 +108,24 @@ class TestSplitDocument:
result = chunker.split_document(doc)
assert len(result) > 1
# First chunk should contain header
assert "h1" in result[0].text
# Only the first: this is what duplicate_headers=False means.
assert all("h1" not in chunk.text for chunk in result[1:])
def test_split_duplicates_header(self):
"""Every chunk carries the header when the flag is set.
Asserting only the first chunk passed even while the flag did nothing:
the implementation cleared the header after the first iteration, so
duplicate_headers was unreachable.
"""
chunker = Chunker(max_tokens=50, min_tokens=5, duplicate_headers=True)
text = "h1\nh2\nh3\n" + "word " * 200
doc = Document(text=text, doc_id="doc1")
result = chunker.split_document(doc)
assert len(result) > 1
# First chunk should contain header
assert "h1" in result[0].text
assert all("h1" in chunk.text for chunk in result)
def test_split_preserves_embedding(self):
chunker = Chunker(max_tokens=50, min_tokens=5)
+4 -2
View File
@@ -15,11 +15,13 @@ from application.parser.chunking_strategies import (
SemanticChunker,
)
from application.parser.schema.base import Document
from application.utils import get_encoding
from application.parser.tokenization import get_token_counter
def _tok(text: str) -> int:
return len(get_encoding().encode(text))
# Chunk budgets are enforced in the embedding model's tokenizer, so a
# test that measures them in cl100k measures the wrong thing.
return get_token_counter().count(text)
@pytest.mark.unit
+107
View File
@@ -0,0 +1,107 @@
"""Chunk sizes must be counted in the embedding model's units, and splitting
must never rewrite the text it splits."""
import pytest
from application.parser import tokenization
from application.parser.tokenization import (
HuggingFaceCounter,
TiktokenCounter,
get_token_counter,
)
SAMPLES = [
"Hello World: DocsGPT ANSWERS Questions.",
"The quick brown fox jumps over the lazy dog. " * 40,
"Comment configurer l'authentification avec une clé API ?",
"def embed(text: str) -> list[float]:\n return model.encode(text)\n",
"Ünïcödé — em-dashes, curly “quotes”, and 日本語 text.",
"a,b,c\n1,2,3\n4,5,6\n" * 30,
]
class _StubEncoding:
"""Whitespace tokenizer standing in for tiktoken."""
def encode_ordinary(self, text):
return [ord(c) for c in text]
def decode(self, ids):
return "".join(chr(i) for i in ids)
@pytest.fixture(autouse=True)
def _clear_cache():
tokenization.reset_cache()
yield
tokenization.reset_cache()
class TestSplittingPreservesText:
"""The property that protects every stored document."""
@pytest.mark.parametrize("text", SAMPLES)
def test_tiktoken_split_reassembles_exactly(self, text, monkeypatch):
monkeypatch.setattr(tokenization, "get_encoding", lambda: _StubEncoding())
counter = TiktokenCounter()
pieces = counter.split(text, 7)
assert "".join(pieces) == text
@pytest.mark.parametrize("text", SAMPLES)
def test_hf_split_reassembles_exactly(self, text, hf_counter):
"""WordPiece lowercases on decode, so splitting must slice, not decode."""
pieces = hf_counter.split(text, 7)
assert "".join(pieces) == text
def test_hf_split_does_not_lowercase(self, hf_counter):
text = "Hello World: DocsGPT ANSWERS Questions."
assert "".join(hf_counter.split(text, 3)) == text
assert "DocsGPT" in "".join(hf_counter.split(text, 3))
@pytest.mark.parametrize("text", SAMPLES)
def test_every_piece_is_within_budget(self, text, hf_counter):
budget = 10
for piece in hf_counter.split(text, budget):
# The final piece can absorb trailing characters the tokenizer
# dropped, so allow a small overshoot there only.
assert hf_counter.count(piece) <= budget + 2
def test_short_text_is_returned_whole(self, hf_counter):
assert hf_counter.split("short", 100) == ["short"]
def test_empty_text_yields_no_pieces(self, hf_counter):
assert hf_counter.split("", 10) == []
def test_zero_budget_is_clamped_not_infinite_loop(self, hf_counter):
pieces = hf_counter.split("some words here to split", 0)
assert "".join(pieces) == "some words here to split"
class TestCounting:
def test_counts_differ_between_tokenizers(self, hf_counter, monkeypatch):
"""The whole point: mpnet and cl100k disagree, so units matter."""
monkeypatch.setattr(tokenization, "get_encoding", lambda: _StubEncoding())
text = "internationalisation tokenization"
assert hf_counter.count(text) != TiktokenCounter().count(text)
def test_empty_text_counts_zero(self, hf_counter):
assert hf_counter.count("") == 0
class TestSelection:
def test_registered_model_uses_its_own_tokenizer(self):
counter = get_token_counter("huggingface_sentence-transformers/all-mpnet-base-v2")
assert isinstance(counter, HuggingFaceCounter)
assert counter.name == "sentence-transformers/all-mpnet-base-v2"
def test_openai_model_falls_back_to_cl100k(self):
"""OpenAI models are served remotely and genuinely count cl100k."""
assert isinstance(get_token_counter("openai_text-embedding-ada-002"), TiktokenCounter)
def test_unreachable_tokenizer_falls_back_rather_than_raising(self, monkeypatch):
monkeypatch.setattr(tokenization, "_load_hf_counter", lambda repo: None)
assert isinstance(get_token_counter("granite-311m"), TiktokenCounter)
def test_counter_is_cached_per_model(self):
first = get_token_counter("granite-311m")
assert get_token_counter("granite-311m") is first
+124
View File
@@ -0,0 +1,124 @@
"""Re-embed script: CLI contract, orchestration, and the FAISS rebuild path."""
from unittest.mock import MagicMock, patch
import pytest
from application.scripts import reembed
class TestCLI:
def test_defaults(self):
args = reembed.build_parser().parse_args([])
assert args.dry_run is False
assert args.sources is None
assert args.batch_size == reembed.DEFAULT_BATCH_SIZE
def test_unsupported_store_exits_without_touching_anything(self):
with patch.object(reembed.settings, "VECTOR_STORE", "qdrant", create=True):
with patch.object(reembed, "run") as run:
assert reembed.main([]) == 2
run.assert_not_called()
def test_supported_stores_are_pgvector_and_faiss(self):
assert set(reembed.SUPPORTED_STORES) == {"pgvector", "faiss"}
def test_sources_are_split_and_trimmed(self):
with patch.object(reembed.settings, "VECTOR_STORE", "faiss", create=True):
with patch.object(reembed, "run", return_value=0) as run:
reembed.main(["--sources", " a , b ,, c "])
assert run.call_args.args[1] == ["a", "b", "c"]
def test_batch_size_is_clamped_to_at_least_one(self):
with patch.object(reembed.settings, "VECTOR_STORE", "faiss", create=True):
with patch.object(reembed, "run", return_value=0) as run:
reembed.main(["--batch-size", "0"])
assert run.call_args.args[2] == 1
class TestRun:
def test_no_sources_is_a_clean_exit(self):
with patch.object(reembed, "list_source_ids", return_value=[]):
assert reembed.run("faiss", None, 8, False) == 0
def test_processes_every_discovered_source(self):
with patch.object(reembed, "list_source_ids", return_value=["a", "b"]):
with patch.object(reembed, "reembed_faiss", return_value=(3, 3)) as handler:
assert reembed.run("faiss", None, 8, False) == 0
assert [call.args[0] for call in handler.call_args_list] == ["a", "b"]
def test_explicit_sources_skip_discovery(self):
with patch.object(reembed, "list_source_ids") as discover:
with patch.object(reembed, "reembed_faiss", return_value=(1, 1)):
reembed.run("faiss", ["only-this"], 8, False)
discover.assert_not_called()
def test_one_failing_source_does_not_stop_the_others(self):
def handler(source_id, batch_size, dry_run):
if source_id == "bad":
raise RuntimeError("boom")
return (1, 1)
with patch.object(reembed, "reembed_faiss", side_effect=handler) as spy:
code = reembed.run("faiss", ["good", "bad", "also-good"], 8, False)
assert code == 1, "a failure must be reported in the exit code"
assert spy.call_count == 3, "later sources must still be attempted"
def test_dry_run_is_reported_as_success(self):
with patch.object(reembed, "reembed_faiss", return_value=(5, 0)):
assert reembed.run("faiss", ["a"], 8, True) == 0
def test_pgvector_uses_the_pgvector_handler(self):
with patch.object(reembed, "reembed_pgvector", return_value=(1, 1)) as handler:
reembed.run("pgvector", ["a"], 8, False)
handler.assert_called_once()
class TestFaissRebuild:
@pytest.fixture
def stores(self):
"""An existing store to read from and the rebuilt one written back."""
existing = MagicMock()
existing.get_chunks.return_value = [
{"doc_id": "1", "text": "alpha", "metadata": {"i": 0}},
{"doc_id": "2", "text": "beta", "metadata": {"i": 1}},
]
rebuilt = MagicMock()
with patch.object(
reembed.VectorCreator, "create_vectorstore", side_effect=[existing, rebuilt]
) as factory:
yield existing, rebuilt, factory
def test_rebuilds_from_stored_text_and_saves(self, stores):
existing, rebuilt, factory = stores
seen, written = reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
assert (seen, written) == (2, 2)
rebuilt.save_local.assert_called_once()
docs = factory.call_args_list[1].kwargs["docs_init"]
assert [d.page_content for d in docs] == ["alpha", "beta"]
assert [d.metadata for d in docs] == [{"i": 0}, {"i": 1}]
def test_existing_index_is_not_deleted(self, stores):
"""The rebuild must not destroy the old index before the new one exists."""
existing, rebuilt, _ = stores
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
existing.delete_index.assert_not_called()
def test_dry_run_reads_but_never_rebuilds(self):
existing = MagicMock()
existing.get_chunks.return_value = [{"doc_id": "1", "text": "a", "metadata": {}}]
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=existing
) as factory:
seen, written = reembed.reembed_faiss("s1", batch_size=8, dry_run=True)
assert (seen, written) == (1, 0)
assert factory.call_count == 1, "no rebuild store may be constructed"
def test_empty_index_is_a_no_op(self):
existing = MagicMock()
existing.get_chunks.return_value = []
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=existing
):
assert reembed.reembed_faiss("s1", batch_size=8, dry_run=False) == (0, 0)
+187
View File
@@ -0,0 +1,187 @@
"""Live pgvector run of the re-embed script.
Uses the ephemeral pytest-postgresql cluster with a stub embeddings model, so
a real ``UPDATE ... ::vector`` round trip is exercised without downloading a
model. Skips when the cluster has no pgvector build.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from application.scripts import reembed
from application.vectorstore import pgvector as pgvector_module
from application.vectorstore.pgvector import PGVectorStore
pytestmark = pytest.mark.integration
DIM = 8
class _Embeddings:
"""Returns a distinct constant per generation, so a rewrite is visible."""
dimension = DIM
def __init__(self, seed: float):
self.seed = seed
self.calls = 0
def embed_documents(self, texts):
self.calls += 1
return [[self.seed] + [0.0] * (DIM - 1) for _ in texts]
def embed_query(self, query):
return [self.seed] + [0.0] * (DIM - 1)
def _dsn(info) -> str:
password = f":{info.password}" if info.password else ""
return f"postgresql://{info.user}{password}@{info.host}:{info.port}/{info.dbname}"
@pytest.fixture(autouse=True)
def _close_pools():
yield
for dsn, pool in list(pgvector_module._POOLS.items()):
try:
pool.close()
except Exception:
pass
pgvector_module._POOLS.pop(dsn, None)
@pytest.fixture
def live_dsn(postgresql, monkeypatch):
try:
with postgresql.cursor() as cursor:
cursor.execute("CREATE EXTENSION vector;")
postgresql.rollback()
except Exception as exc:
postgresql.rollback()
pytest.skip(f"pgvector extension unavailable: {exc}")
dsn = _dsn(postgresql.info)
from application.core import settings as settings_module
settings = settings_module.settings
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector", raising=False)
monkeypatch.setattr(settings, "PGVECTOR_CONNECTION_STRING", dsn, raising=False)
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "PGVECTOR_IVFFLAT_PROBES", None, raising=False)
monkeypatch.setattr(settings, "PGVECTOR_POOL_MAX_SIZE", 4, raising=False)
monkeypatch.setattr(settings, "EMBEDDINGS_NAME", "granite-311m", raising=False)
return dsn
def _seed(dsn, source_id, texts, embeddings):
"""Create the schema and insert ``texts`` embedded by ``embeddings``."""
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=embeddings,
):
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
PGVectorStore.create_schema(conn, dimension=DIM)
conn.commit()
store.add_texts(list(texts), metadatas=[{"i": i} for i in range(len(texts))])
store.close()
def _as_list(vector):
"""Normalise a stored vector to a list of floats.
Depending on whether pgvector's adapter is registered on the reading
connection, the value comes back as a ``Vector`` or as its text form
``'[1,0,...]'``. Both are valid; the assertions should not care.
"""
if vector is None:
return []
if hasattr(vector, "to_list"):
return list(vector.to_list())
if isinstance(vector, str):
return [float(part) for part in vector.strip("[]").split(",") if part]
return list(vector)
def _vectors(dsn, source_id):
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT text, embedding FROM documents WHERE source_id = %s ORDER BY id",
(source_id,),
)
# pgvector hands back a ``Vector``; normalise to a plain list so the
# assertions read the same whichever adapter is registered.
return [(text, _as_list(vector)) for text, vector in cursor.fetchall()]
finally:
cursor.close()
store.close()
TEXTS = ["alpha document", "beta document", "gamma document"]
class TestReembedPgvectorLive:
def test_rewrites_vectors_and_preserves_text(self, live_dsn):
_seed(live_dsn, "src-a", TEXTS, _Embeddings(1.0))
before = _vectors(live_dsn, "src-a")
assert [row[0] for row in before] == TEXTS
assert all(row[1][0] == pytest.approx(1.0) for row in before)
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
seen, written = reembed.reembed_pgvector("src-a", batch_size=2, dry_run=False)
assert (seen, written) == (3, 3)
after = _vectors(live_dsn, "src-a")
# Text is untouched; only the vectors moved.
assert [row[0] for row in after] == TEXTS
assert all(row[1][0] == pytest.approx(9.0) for row in after)
def test_dry_run_counts_without_writing(self, live_dsn):
_seed(live_dsn, "src-b", TEXTS, _Embeddings(1.0))
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
seen, written = reembed.reembed_pgvector("src-b", batch_size=2, dry_run=True)
assert (seen, written) == (3, 0)
assert new_model.calls == 0, "dry run must not embed"
assert all(row[1][0] == pytest.approx(1.0) for row in _vectors(live_dsn, "src-b"))
def test_batches_are_respected(self, live_dsn):
_seed(live_dsn, "src-c", TEXTS, _Embeddings(1.0))
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
reembed.reembed_pgvector("src-c", batch_size=2, dry_run=False)
assert new_model.calls == 2, "3 chunks at batch 2 is two embed calls"
def test_only_the_named_source_is_touched(self, live_dsn):
_seed(live_dsn, "src-d", TEXTS, _Embeddings(1.0))
_seed(live_dsn, "src-e", TEXTS, _Embeddings(1.0))
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=_Embeddings(9.0),
):
reembed.reembed_pgvector("src-d", batch_size=64, dry_run=False)
assert all(row[1][0] == pytest.approx(9.0) for row in _vectors(live_dsn, "src-d"))
assert all(row[1][0] == pytest.approx(1.0) for row in _vectors(live_dsn, "src-e"))
def test_source_discovery_lists_every_source(self, live_dsn):
_seed(live_dsn, "src-f", TEXTS, _Embeddings(1.0))
_seed(live_dsn, "src-g", TEXTS, _Embeddings(1.0))
assert reembed.list_source_ids("pgvector") == ["src-f", "src-g"]
+25 -23
View File
@@ -263,11 +263,11 @@ class TestEmbeddingsSingleton:
def test_get_instance_hf_ignores_positional_key(
self, mock_get_wrapper, mock_settings
):
"""A stray key must not reach the zero-arg HuggingFace factory.
"""A stray key must not reach the wrapper for a registered model.
The factories are ``lambda: EmbeddingsWrapper(...)``, so a caller that
passed ``settings.EMBEDDINGS_KEY`` positionally used to blow up with
``TypeError: <lambda>() takes 0 positional arguments``.
Registered models take their whole configuration from the registry, so
a caller that passes ``settings.EMBEDDINGS_KEY`` positionally (as the
vector stores do) must have it dropped rather than forwarded.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_wrapper_cls = Mock()
@@ -278,9 +278,8 @@ class TestEmbeddingsSingleton:
result = EmbeddingsSingleton.get_instance(HF_MPNET, None)
assert result is mock_instance
mock_wrapper_cls.assert_called_once_with(
"sentence-transformers/all-mpnet-base-v2"
)
# The configured name is passed through; the registry maps it to a repo.
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base._get_embeddings_wrapper")
@@ -293,9 +292,7 @@ class TestEmbeddingsSingleton:
EmbeddingsSingleton.get_instance(HF_MPNET, openai_api_key="sk-nope")
mock_wrapper_cls.assert_called_once_with(
"sentence-transformers/all-mpnet-base-v2"
)
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
# --- BaseVectorStore ---
@@ -401,12 +398,15 @@ class TestBaseVectorStore:
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
@patch("os.path.exists")
def test_get_embeddings_huggingface_local_model(
self, mock_exists, mock_get_instance, mock_settings
def test_get_embeddings_registered_model_passes_configured_name(
self, mock_get_instance, mock_settings
):
"""No bundled-path branch any more: the name goes straight through.
FastEmbed resolves artifacts through its own cache (warmed in the
image), so the old ``/app/models/...`` probe has no job to do.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_exists.side_effect = lambda p: p == "/app/models/all-mpnet-base-v2"
mock_emb = Mock()
mock_get_instance.return_value = mock_emb
@@ -415,7 +415,9 @@ class TestBaseVectorStore:
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
assert result is mock_emb
mock_get_instance.assert_called_with("/app/models/all-mpnet-base-v2")
mock_get_instance.assert_called_with(
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
@@ -518,18 +520,18 @@ class TestGetEmbeddingsResolver:
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base._get_embeddings_wrapper")
@patch("os.path.exists")
def test_uses_local_model_path_and_caches_it(
self, mock_exists, mock_get_wrapper, mock_settings
def test_repeated_resolution_loads_one_model(
self, mock_get_wrapper, mock_settings
):
"""With the bundled model present, the instance is keyed by its path.
"""A second call must not load a second copy of the model.
A second call must not load a second copy of the model.
The instance is keyed by the configured name. It used to be keyed by a
bundled filesystem path when one happened to exist, which meant the
same model could be cached twice under two keys.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_settings.EMBEDDINGS_NAME = HF_MPNET
mock_settings.EMBEDDINGS_KEY = None
mock_exists.side_effect = lambda path: path == LOCAL_MPNET
mock_wrapper_cls = Mock()
mock_wrapper_cls.return_value = Mock()
mock_get_wrapper.return_value = mock_wrapper_cls
@@ -538,8 +540,8 @@ class TestGetEmbeddingsResolver:
second = get_embeddings()
assert first is second
assert set(EmbeddingsSingleton._instances) == {LOCAL_MPNET}
mock_wrapper_cls.assert_called_once_with(LOCAL_MPNET)
assert set(EmbeddingsSingleton._instances) == {HF_MPNET}
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
@patch("application.vectorstore.base.settings")
def test_remote_when_base_url_configured(self, mock_settings):
+137 -126
View File
@@ -1,156 +1,167 @@
from unittest.mock import MagicMock, Mock, patch
"""Local embeddings run through FastEmbed, configured from the model registry."""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from application.vectorstore import embeddings_local
from application.vectorstore.embeddings_local import EmbeddingsWrapper
from application.vectorstore.model_registry import GRANITE_97M, MPNET
@pytest.mark.unit
class TestEmbeddingsWrapper:
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_success(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
@pytest.fixture(autouse=True)
def _clear_registration():
"""``add_custom_model`` writes to a FastEmbed global; keep tests isolated."""
embeddings_local._registered.clear()
yield
embeddings_local._registered.clear()
wrapper = EmbeddingsWrapper("test-model")
mock_st_cls.assert_called_once()
assert wrapper.dimension == 768
@pytest.fixture
def fake_fastembed():
"""Patch FastEmbed so no model is downloaded or run."""
text_embedding = MagicMock()
instance = MagicMock()
instance.embed.return_value = iter([np.array([0.1, 0.2, 0.3])])
text_embedding.return_value = instance
with patch("fastembed.TextEmbedding", text_embedding):
yield text_embedding, instance
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_falls_back_to_legacy_dimension_api(self, mock_st_cls):
"""sentence-transformers < 5.4 only exposes get_sentence_embedding_dimension."""
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
del mock_model.get_embedding_dimension
mock_model.get_sentence_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
class TestRegistryDrivenLoading:
def test_registered_model_loads_by_repo_not_by_configured_name(self, fake_fastembed):
text_embedding, _ = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
assert wrapper.dimension == MPNET.dimension
wrapper = EmbeddingsWrapper("test-model")
def test_legacy_alias_resolves_to_the_same_model(self, fake_fastembed):
text_embedding, _ = fake_fastembed
EmbeddingsWrapper("huggingface_sentence-transformers-all-mpnet-base-v2")
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
assert wrapper.dimension == 768
mock_model.get_sentence_embedding_dimension.assert_called_once()
def test_dimension_comes_from_registry_without_running_the_model(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(GRANITE_97M.name)
assert wrapper.dimension == 384
instance.embed.assert_not_called()
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_failure(self, mock_st_cls):
mock_st_cls.side_effect = Exception("model not found")
def test_unknown_model_is_treated_as_a_hf_repo(self, fake_fastembed):
text_embedding, _ = fake_fastembed
wrapper = EmbeddingsWrapper("some-org/custom-embedder")
assert text_embedding.call_args.kwargs["model_name"] == "some-org/custom-embedder"
# No registry entry means no known width, so it must be probed.
assert wrapper.dimension == 3
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_load_failure_names_the_model_and_the_known_ones(self):
with patch("fastembed.TextEmbedding", side_effect=OSError("no such repo")):
with pytest.raises(RuntimeError) as excinfo:
EmbeddingsWrapper("broken/model")
message = str(excinfo.value)
assert "broken/model" in message
assert MPNET.name in message
with pytest.raises(Exception, match="model not found"):
EmbeddingsWrapper("bad-model")
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_none_model(self, mock_st_cls):
mock_st_cls.return_value = None
class TestSettingsPassthrough:
def test_threads_forwarded_when_configured(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", 2, create=True):
EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["threads"] == 2
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_threads_omitted_when_unset(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", None, create=True):
EmbeddingsWrapper(MPNET.name)
assert "threads" not in text_embedding.call_args.kwargs
with pytest.raises((ValueError, AttributeError)):
EmbeddingsWrapper("bad-model")
def test_cache_dir_forwarded_when_configured(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_CACHE_DIR", "/models", create=True):
EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["cache_dir"] == "/models"
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_null_first_module(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = None
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
class TestEmbedding:
def test_embed_documents_returns_plain_lists(self, fake_fastembed):
_, instance = fake_fastembed
instance.embed.return_value = iter([np.array([1.0, 2.0]), np.array([3.0, 4.0])])
wrapper = EmbeddingsWrapper(MPNET.name)
assert wrapper.embed_documents(["a", "b"]) == [[1.0, 2.0], [3.0, 4.0]]
with pytest.raises(ValueError, match="failed to load properly"):
EmbeddingsWrapper("bad-model")
def test_embed_documents_short_circuits_on_empty_input(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.reset_mock()
assert wrapper.embed_documents([]) == []
instance.embed.assert_not_called()
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_embed_query(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1, 0.2, 0.3]))
mock_st_cls.return_value = mock_model
def test_embed_query_returns_a_single_vector(self, fake_fastembed):
_, instance = fake_fastembed
instance.embed.return_value = iter([np.array([0.5, 0.6])])
wrapper = EmbeddingsWrapper(MPNET.name)
assert wrapper.embed_query("hello") == [0.5, 0.6]
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_call_dispatches_on_input_type(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.return_value = iter([np.array([1.0])])
assert wrapper("text") == [1.0]
instance.embed.return_value = iter([np.array([1.0]), np.array([2.0])])
assert wrapper(["a", "b"]) == [[1.0], [2.0]]
wrapper = EmbeddingsWrapper("model")
result = wrapper.embed_query("hello world")
def test_call_rejects_other_types(self, fake_fastembed):
wrapper = EmbeddingsWrapper(MPNET.name)
with pytest.raises(ValueError):
wrapper(42)
mock_model.encode.assert_called_once_with("hello world")
assert result == [0.1, 0.2, 0.3]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_embed_documents(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(
tolist=Mock(return_value=[[0.1, 0.2], [0.3, 0.4]])
class TestRegistrationIsIdempotent:
def test_model_registered_once_per_process(self, fake_fastembed):
text_embedding, _ = fake_fastembed
EmbeddingsWrapper(MPNET.name)
EmbeddingsWrapper(MPNET.name)
assert text_embedding.add_custom_model.call_count == 1
class TestLengthSortedBatching:
"""Grouping by length is a throughput/memory win, but order is a contract."""
def _wrapper(self, fake_fastembed, batch_size):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.side_effect = lambda texts, batch_size=None: iter(
[np.array([float(len(t))]) for t in texts]
)
mock_st_cls.return_value = mock_model
return wrapper, instance
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_output_order_matches_input_order(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
texts = ["dddd", "a", "ccc", "bb", "eeeee"]
out = wrapper.embed_documents(texts)
# Each stub vector encodes its own text length, so a reordered result
# is immediately visible.
assert out == [[4.0], [1.0], [3.0], [2.0], [5.0]]
wrapper = EmbeddingsWrapper("model")
result = wrapper.embed_documents(["doc1", "doc2"])
def test_inputs_are_grouped_by_length_before_batching(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 2)
wrapper.embed_documents(["dddd", "a", "ccc", "bb", "eeeee"])
sent = instance.embed.call_args.args[0]
assert [len(t) for t in sent] == [1, 2, 3, 4, 5]
mock_model.encode.assert_called_with(["doc1", "doc2"])
assert result == [[0.1, 0.2], [0.3, 0.4]]
def test_single_batch_is_not_reordered(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 32, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 32)
texts = ["dddd", "a", "ccc"]
out = wrapper.embed_documents(texts)
assert instance.embed.call_args.args[0] == texts
assert out == [[4.0], [1.0], [3.0]]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_string(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1]))
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
wrapper = EmbeddingsWrapper("model")
result = wrapper("hello")
assert result == [0.1]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_list(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(
tolist=Mock(return_value=[[0.1], [0.2]])
)
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
wrapper = EmbeddingsWrapper("model")
result = wrapper(["a", "b"])
assert result == [[0.1], [0.2]]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_invalid_type(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
wrapper = EmbeddingsWrapper("model")
with pytest.raises(ValueError, match="Input must be a string or a list"):
wrapper(123)
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_trust_remote_code_default(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
EmbeddingsWrapper("model")
call_kwargs = mock_st_cls.call_args[1]
assert call_kwargs["trust_remote_code"] is True
def test_duplicate_texts_are_handled(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
out = wrapper.embed_documents(["aa", "b", "aa", "ccc"])
assert out == [[2.0], [1.0], [2.0], [3.0]]
+23 -6
View File
@@ -185,15 +185,20 @@ class TestFaissStoreAssertEmbeddingDimensions:
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
populated.assert_embedding_dimensions(Mock(dimension=768))
def test_missing_dimension_attr_raises(self, populated):
def test_unknown_dimension_defers_rather_than_raising(self, populated):
"""A remote model reports no width until its first call.
Refusing to open the index in that window would break startup for a
perfectly valid remote configuration, so an unknown width is deferred,
not treated as a mismatch.
"""
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = (
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
embeddings = Mock()
del embeddings.dimension
with pytest.raises(AttributeError, match="'dimension' attribute not found"):
populated.assert_embedding_dimensions(embeddings)
embeddings.dimension = None
assert populated.assert_embedding_dimensions(embeddings) is None
def test_dimension_match_passes(self, populated):
with patch("application.vectorstore.faiss.settings") as mock_settings:
@@ -202,10 +207,22 @@ class TestFaissStoreAssertEmbeddingDimensions:
)
assert populated.assert_embedding_dimensions(Mock(dimension=3)) is None
def test_non_huggingface_skips_dimension_check(self, populated):
def test_mismatch_is_caught_for_every_model_not_just_mpnet(self, populated):
"""The check used to run only when EMBEDDINGS_NAME was mpnet.
That skipped exactly the case it exists for: an index built with one
model being opened under a different one.
"""
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = "openai_text-embedding-ada-002"
assert populated.assert_embedding_dimensions(Mock(dimension=1536)) is None
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
populated.assert_embedding_dimensions(Mock(dimension=1536))
def test_mismatch_message_points_at_the_reembed_script(self, populated):
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = "granite-311m"
with pytest.raises(ValueError, match="application.scripts.reembed"):
populated.assert_embedding_dimensions(Mock(dimension=768))
@pytest.mark.unit
+88
View File
@@ -0,0 +1,88 @@
"""The registry is the single source of truth for embedding-model facts."""
import pytest
from application.vectorstore import model_registry as reg
class TestResolve:
def test_resolves_canonical_name(self):
assert reg.resolve(reg.MPNET.name) is reg.MPNET
@pytest.mark.parametrize(
"alias",
[
"huggingface_sentence-transformers-all-mpnet-base-v2",
"sentence-transformers/all-mpnet-base-v2",
"all-mpnet-base-v2",
],
)
def test_resolves_legacy_spellings_of_mpnet(self, alias):
"""Every spelling the old factory dict accepted must still work."""
assert reg.resolve(alias) is reg.MPNET
def test_resolution_is_case_insensitive_and_trims(self):
assert reg.resolve(" GRANITE-311M ") is reg.GRANITE_311M
def test_unknown_name_is_none_not_an_error(self):
"""Unknown names are a valid configuration: an arbitrary HF repo."""
assert reg.resolve("some-org/some-model") is None
def test_empty_and_none_resolve_to_none(self):
assert reg.resolve(None) is None
assert reg.resolve("") is None
class TestModelFacts:
def test_granite_311m_matches_the_existing_column_width(self):
"""768 is what makes granite a drop-in for an mpnet index."""
assert reg.GRANITE_311M.dimension == reg.MPNET.dimension == 768
def test_granite_97m_is_narrower(self):
assert reg.GRANITE_97M.dimension == 384
def test_pooling_and_normalisation_are_recorded(self):
assert reg.MPNET.pooling == "mean"
assert reg.GRANITE_311M.pooling == "cls"
assert all(m.normalize for m in reg.MODELS)
def test_granite_context_is_much_wider_than_mpnet(self):
assert reg.GRANITE_311M.max_input_tokens == 32768
assert reg.MPNET.max_input_tokens == 384
def test_local_runners_carry_a_repo_and_onnx_file(self):
for model in reg.MODELS:
if model.provider == "fastembed":
assert model.repo, f"{model.name} has no repo"
assert model.onnx_file, f"{model.name} has no onnx_file"
def test_openai_model_needs_no_local_artifacts(self):
assert reg.OPENAI_ADA_002.provider == "openai"
assert reg.OPENAI_ADA_002.repo is None
class TestHelpers:
def test_dimension_for_known_and_unknown(self):
assert reg.dimension_for(reg.GRANITE_97M.name) == 384
assert reg.dimension_for("nope/nope") is None
def test_max_input_tokens_for_known_and_unknown(self):
assert reg.max_input_tokens_for("granite-311m") == 32768
assert reg.max_input_tokens_for("nope/nope") is None
def test_known_names_lists_canonical_spellings(self):
names = reg.known_names()
assert reg.MPNET.name in names
assert reg.GRANITE_311M.name in names
def test_defaults_point_at_the_intended_models(self):
"""Existing installs stay on mpnet; new installs get granite."""
assert reg.DEFAULT_LEGACY == reg.MPNET.name
assert reg.DEFAULT_NEW_INSTALL == reg.GRANITE_311M.name
def test_no_alias_collisions_between_models(self):
seen = {}
for model in reg.MODELS:
for key in (model.name, *model.aliases):
assert key.lower() not in seen, f"{key} claimed twice"
seen[key.lower()] = model
@@ -83,3 +83,48 @@ def test_query_path_is_truncated(monkeypatch):
sent = captured["payload"]["input"]
assert sent == enc.decode(enc.encode(long_text)[:10])
class TestInputLimitResolution:
"""The cap falls back to the model's own context window."""
def _remote(self, model_name):
from application.vectorstore.base import RemoteEmbeddings
return RemoteEmbeddings(
api_url="http://embeddings", model_name=model_name, api_key=None
)
def test_explicit_setting_wins(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 123)
assert self._remote("granite-311m")._resolve_input_limit() == 123
def test_registered_model_supplies_its_own_ceiling(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
assert self._remote("granite-311m")._resolve_input_limit() == 32768
# mpnet genuinely stops at 384; sending more is paid for and discarded.
assert self._remote("all-mpnet-base-v2")._resolve_input_limit() == 384
def test_unknown_model_stays_unlimited(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
assert self._remote("some-org/mystery")._resolve_input_limit() is None
def test_non_positive_setting_falls_through_to_the_registry(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 0)
assert self._remote("granite-97m")._resolve_input_limit() == 32768
def test_dimension_is_taken_from_the_registry(self):
assert self._remote("granite-97m").dimension == 384
assert self._remote("granite-311m").dimension == 768
def test_unknown_model_dimension_is_probed_not_assumed(self):
"""The old hardcoded 768 made the probe below unreachable."""
assert self._remote("some-org/mystery").dimension is None