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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,20 @@ EMBEDDER=fake LLM=fake ./.venv/bin/python -m uvicorn vaultrag.main:app --reload
Tests need no API keys: they use a deterministic offline embedder and a scripted LLM, because
access control is not a semantic question and shouldn't need a 90MB model download to verify.

For real use, set `EMBEDDER=local` (sentence-transformers, free, no key) and `LLM=groq` with a
`GROQ_API_KEY`. Zero cost either way.
For real use, set `EMBEDDER=local` (sentence-transformers, free, no key) and
`LLM=groq` with a `GROQ_API_KEY`. Zero cost either way.

### Changing the embedding model

`EMBED_MODEL` defaults to `sentence-transformers/all-MiniLM-L6-v2`, which produces
384-dimensional embeddings.

If you change `EMBED_MODEL`, the model's embedding dimension must match the
`vector(384)` column in `vaultrag/schema.sql`. Changing to a model with a different
dimension requires updating the database schema and re-ingesting the corpus.

VaultRAG validates the embedding dimension when the local model is loaded and raises
a clear error if it does not match the database schema.

## Status

Expand Down
74 changes: 74 additions & 0 deletions tests/test_embeddings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from types import SimpleNamespace

import pytest

from vaultrag.embeddings import DIMS, LocalEmbedder


class FakeSentenceTransformer:
def __init__(self, model_name: str, dims: int) -> None:
self.model_name = model_name
self._dims = dims

def get_sentence_embedding_dimension(self) -> int:
return self._dims

def encode(
self,
texts: list[str],
normalize_embeddings: bool,
show_progress_bar: bool,
) -> list[list[float]]:
return [[0.0] * self._dims for _ in texts]


def install_fake_sentence_transformers(monkeypatch, dims: int):
def constructor(model_name: str):
return FakeSentenceTransformer(model_name, dims)

monkeypatch.setitem(
__import__("sys").modules,
"sentence_transformers",
SimpleNamespace(SentenceTransformer=constructor),
)


def test_local_embedder_detects_model_dimensions(monkeypatch):
install_fake_sentence_transformers(monkeypatch, DIMS)

embedder = LocalEmbedder("fake-model")

assert embedder.dims == DIMS


def test_local_embedder_embed_works_with_valid_dimensions(monkeypatch):
install_fake_sentence_transformers(monkeypatch, DIMS)

embedder = LocalEmbedder("fake-model")

result = embedder.embed(["hello", "world"])

assert len(result) == 2
assert all(len(vector) == DIMS for vector in result)


def test_local_embedder_rejects_wrong_dimensions_on_repeated_access(monkeypatch):
install_fake_sentence_transformers(monkeypatch, 768)

embedder = LocalEmbedder("sentence-transformers/all-mpnet-base-v2")

for _ in range(2):
with pytest.raises(
ValueError,
match=r"all-mpnet-base-v2.*768.*384.*vector\(384\)",
):
embedder.dims


def test_local_embedder_error_mentions_schema(monkeypatch):
install_fake_sentence_transformers(monkeypatch, 1024)

embedder = LocalEmbedder("another-model")

with pytest.raises(ValueError, match=r"vector\(384\)"):
embedder.dims
94 changes: 64 additions & 30 deletions vaultrag/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

Two implementations, one interface:

- LocalEmbedder: sentence-transformers, runs on CPU, no API key, free forever. This is the
default and the one that matters. Paying an API per embedding for a document corpus is a
choice, not a requirement.
- FakeEmbedder: deterministic hash-based vectors. Not semantically meaningful, but stable and
instant, which is exactly what the ACL tests need. Those tests are about who can see what, and
they should not need a 90MB model download or a network call to run.

The interface is one method so swapping providers is a config change, not a refactor.
- LocalEmbedder: sentence-transformers, runs on CPU, no API key, free forever.
- FakeEmbedder: deterministic hash-based vectors. Used for tests.

The local embedder detects the actual model dimension and verifies that it
matches the database schema dimension.
"""

from __future__ import annotations
Expand All @@ -18,63 +15,93 @@
import math
from typing import Protocol

DIMS = 384 # all-MiniLM-L6-v2. If you change the model, change the schema's vector(384) too.

DIMS = 384


class Embedder(Protocol):
@property
def dims(self) -> int: ...

def embed(self, texts: list[str]) -> list[list[float]]: ...


class FakeEmbedder:
"""Deterministic, offline, instant. For tests.

Hashes text into a fixed-dimension unit vector. Same text always gives the same vector, and
different text gives a different one, which is all a retrieval test needs. It is NOT semantic:
"cat" and "kitten" are unrelated here. Any test that depends on semantic similarity should use
the real embedder or, better, not be a unit test.
"""
"""Deterministic, offline, instant embedder for tests."""

dims = DIMS

def embed(self, texts: list[str]) -> list[list[float]]:
return [self._one(t) for t in texts]

def _one(self, text: str) -> list[float]:
# Expand a digest into DIMS floats by rehashing with a counter.
vec: list[float] = []
counter = 0

while len(vec) < DIMS:
h = hashlib.sha256(f"{text}:{counter}".encode()).digest()
vec.extend(b / 255.0 - 0.5 for b in h)
counter += 1

vec = vec[:DIMS]
return _normalize(vec)


class LocalEmbedder:
"""sentence-transformers on CPU. Free, no key, no network after the first download.
"""sentence-transformers on CPU.

Loaded lazily so that importing this module (which the tests do) does not pull in torch.
The model is loaded lazily and its actual embedding dimension is checked
against the dimension expected by the database schema.
"""

dims = DIMS

def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2") -> None:
def __init__(
self,
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
) -> None:
self._model_name = model_name
self._model = None
self._dims: int | None = None

@property
def dims(self) -> int:
if self._dims is None:
self._load()

assert self._dims is not None
return self._dims

def _load(self):
if self._model is None:
from sentence_transformers import SentenceTransformer # imported lazily on purpose

self._model = SentenceTransformer(self._model_name)
from sentence_transformers import SentenceTransformer

model = SentenceTransformer(self._model_name)
dims = model.get_sentence_embedding_dimension()

if dims != DIMS:
raise ValueError(
f"Embedding model {self._model_name!r} produces "
f"{dims}-dimensional vectors, but VaultRAG expects "
f"{DIMS} dimensions. The embedding column in "
f"vaultrag/schema.sql is vector({DIMS}). Changing "
f"EMBED_MODEL requires updating the schema and "
f"re-ingesting the corpus."
)

self._model = model
self._dims = dims

return self._model


def embed(self, texts: list[str]) -> list[list[float]]:
model = self._load()
# normalize_embeddings=True so cosine distance in pgvector behaves, and so the schema's
# vector_cosine_ops index is the right choice.
arr = model.encode(texts, normalize_embeddings=True, show_progress_bar=False)

arr = model.encode(
texts,
normalize_embeddings=True,
show_progress_bar=False,
)

return [list(map(float, row)) for row in arr]


Expand All @@ -83,9 +110,16 @@ def _normalize(vec: list[float]) -> list[float]:
return [v / norm for v in vec]


def get_embedder(kind: str = "local", model: str = "sentence-transformers/all-MiniLM-L6-v2") -> Embedder:
def get_embedder(
kind: str = "local",
model: str = "sentence-transformers/all-MiniLM-L6-v2",
) -> Embedder:
if kind == "fake":
return FakeEmbedder()

if kind == "local":
return LocalEmbedder(model)
raise ValueError(f"unknown embedder: {kind!r} (expected 'local' or 'fake')")

raise ValueError(
f"unknown embedder: {kind!r} (expected 'local' or 'fake')"
)
Loading