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

+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)