Skip to content
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
timeout: 5s
retries: 10
db:
image: postgres:17
image: pgvector/pgvector:pg18
environment:
POSTGRES_DB: database_app_test
POSTGRES_USER: app
Expand Down
61 changes: 61 additions & 0 deletions docs/adr/063-ai-embeddings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 063 — AI embeddings with a Laravel-style fake

- Date: 2026-11-01
- Status: Accepted

## Problem

The `ai` package covers agents, images, audio and transcription, but has no way to turn text into vectors. Apps that need semantic search or retrieval call a provider SDK by hand, so they cannot switch providers through `AIConfig` and have nothing to fake in tests. Laravel's AI SDK offers `Embeddings::for([...])->dimensions(n)->generate($provider, $model)` and an `Embeddings::fake()` with `assertGenerated`, `assertNotGenerated`, `assertNothingGenerated`, `preventStrayEmbeddings` and `fakeEmbedding`.

## Alternatives

1. **An `EmbeddingsFactory` ABC with one class per vendor SDK**, like `ImageFactory` and `AudioFactory`. Rejected: it rewrites what LangChain already ships, and chat agents already go through LangChain's `init_chat_model`.
2. **Have apps build LangChain `Embeddings` objects themselves.** Rejected: no shared config, no fake, no assertions.
3. **A context-manager fake, like `AgentFake`.** Rejected for this API: Laravel's call shape is `Embeddings::fake()` followed by static assertions, and nothing has to be entered or exited to embed.
4. **Port embedding caching.** Deferred: it needs a cache store, which the package does not have, and it is not needed to generate or fake embeddings.
5. **Accept text in the vector query methods and embed it automatically, as Laravel does.** Rejected: the query builder is synchronous and an embedding call is async. Callers run `await Str.of(text).to_embeddings()` first, and passing a `str` raises `TypeError` with that hint.
6. **Bind vectors through a pgvector-specific asyncpg codec.** Rejected: pgvector accepts the JSON text form `[0.1, 0.2]` for an untyped parameter, which is also how Laravel binds them. No driver registration is needed.
7. **A fluent builder over LangChain's `init_embeddings`, with a class-level fake.** Chosen.

## Decision

- `Embeddings.of(inputs).dimensions(n).generate(provider=None, model=None)` returns an `EmbeddingsResponse(embeddings, provider, model)` with `first()`. `of` replaces Laravel's `for`, which is a Python keyword; `Image.of` and `Audio.of` use the same name.
- The provider comes from `AIConfig.default_embeddings` (env `AI_DEFAULT_EMBEDDINGS_PROVIDER`, default `openai`) through a new `ModelType.EMBEDDINGS`, whose key is `default_embeddings`. Default models: OpenAI `text-embedding-3-small`, Google `gemini-embedding-001`, Bedrock `amazon.titan-embed-text-v2:0`.
- The real call is `init_embeddings(model, provider=...)` and then `aembed_documents(inputs)`. A table maps each `Lab` to its LangChain embeddings provider key and dimensions option: OpenAI `dimensions`, Google `output_dimensionality`, Bedrock `model_kwargs={"dimensions": n}`. The key from config is passed as `api_key` for OpenAI and Google. Bedrock gets `Lab.get_client_options()` (region, credentials, profile). A provider without embeddings (Anthropic, ElevenLabs) raises `ValueError`.
- Every call builds an `EmbeddingsPrompt(inputs, dimensions, provider, model)` with `contains(text)`, the same object the fake records and passes to callbacks.
- `Embeddings.fake(responses=None)` installs a class-level `EmbeddingsFake` and returns it:
- with no responses, each input gets a random unit vector of the prompt's dimensions (1536 when unset), from `Embeddings.fake_embedding(dimensions)`;
- a list gives one response per call, each a list of vectors; once the list runs out, calls fall back to random vectors;
- a callable receives the `EmbeddingsPrompt` and returns the vectors.
- `prevent_stray_embeddings()` makes a call with no queued or callable response raise `RuntimeError`.
- `Embeddings.assert_generated`, `assert_not_generated` and `assert_nothing_generated` check the recorded prompts. They accept a callable predicate or a string, which matches through `contains`. They raise `RuntimeError` when embeddings are not faked. `Embeddings.reset_fake()` removes the fake, and `Ai.reset_fakes()` removes it too, so a test that only resets agent fakes does not leak the embeddings fake.
- `Str.of(text).to_embeddings(provider=None, model=None, dimensions=None)` is an async `Stringable` method that returns one vector through `Embeddings`, so it is faked by `Embeddings.fake()`.
- pgvector columns: `table.vector(column, dimensions)` adds a `VECTOR(n)` column on PostgreSQL and raises `ValueError` on any other platform. `.index()` picks the index type from the column type: when any indexed column was added as `vector` in the same blueprint, whether named or taken from the last column, it creates an HNSW index with `vector_cosine_ops`. A btree index on `vector(1536)` fails every insert with `index row size exceeds btree maximum`. `table.vector_index(column=None, name=None)` creates the HNSW index explicitly, for a vector column that already exists, and raises `ValueError` on other platforms. `schema.ensure_vector_extension_exists()` runs `CREATE EXTENSION IF NOT EXISTS vector`.
- Vector queries, PostgreSQL only (other grammars raise `ValueError`), using cosine distance `<=>`:
- `select_vector_distance(column, vector, as_=None)` adds `(col <=> ?) AS col_distance`;
- `where_vector_distance_less_than(column, vector, max_distance)` and its `or_` variant;
- `order_by_vector_distance(column, vector)`, closest first;
- `where_vector_similar_to(column, vector, min_similarity=0.6, order=True)` filters on `distance <= 1 - min_similarity` and orders by distance, like Laravel's `whereVectorSimilarTo`.
The vector is bound as a JSON array of floats. The same methods are available on `Model`.
Column and alias names go through `PostgresGrammar.wrap_identifier`, which doubles any embedded `"`, so a name cannot break out of its identifier. An unqualified column is prefixed with the query alias or table (`"documents"."embedding"`), like other where clauses, so it stays unambiguous in joins.
- Model fields annotated with parameterized `list[...]` or `dict[...]` (for example `embedding: list[float]`) now get `JsonCast`, as bare `list` and `dict` already did. Before this they fell back to the string cast. `JsonCast.set(None)` now returns `None` (SQL `NULL`) instead of the JSON text `'null'`, which pgvector rejects and which other casts never produced.
- A raw `ORDER BY` that follows another order term is now separated by a comma. Before this, `order_by("a").order_by_raw("b")` produced invalid SQL.
- Laravel's queued assertions are not ported, because the package has no queued generation for any modality.

## Validation

`fastapi_startkit/tests/ai/test_embeddings.py`:

- `generate` passes the resolved provider key, model, dimension option, API key and Bedrock client options to `init_embeddings` and returns the vectors from `aembed_documents` (LangChain patched);
- the default provider and model come from `AIConfig`, and explicit arguments override them;
- an unsupported provider raises `ValueError`;
- `fake()` returns unit vectors of the requested or default dimensions, one per input;
- list responses are used in order, then fall back to random vectors;
- callable responses receive the prompt;
- `prevent_stray_embeddings` raises once responses run out;
- `assert_generated` / `assert_not_generated` / `assert_nothing_generated` pass and fail as expected with callables and strings;
- assertions without a fake raise.

`tests/support/test_string.py` checks that `to_embeddings` returns the faked vector and forwards provider, model and dimensions. `tests/masoniteorm/query/grammars/test_vector_grammar.py` checks the SQL and binding order of each vector query method, table and alias prefixes, the escaping of an injected `"` in columns and aliases, the order-by comma fix, the `TypeError` for text and the `ValueError` on non-Postgres grammars. `tests/masoniteorm/schema/test_vector_schema.py` checks `VECTOR(n)`, the HNSW index on create and alter for bare and named `.index()`, `vector_index()` on an existing column, btree for other columns, the extension SQL and the errors on other platforms. `tests/masoniteorm/models/test_caster.py` checks the JSON cast for generic annotations and that `None` is stored as `NULL`. `test_embeddings.py` checks that `Ai.reset_fakes()` clears the embeddings fake. `tests/masoniteorm/postgres/test_postgres_vector.py` runs against a live `pgvector/pgvector` database: it round-trips `list[float]` and checks similarity filtering, distance selection and ordering.

`uv run pytest --ignore=tests/masoniteorm/postgres --cov` passes with coverage at or above 80%.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [059](059-subquery-where-in-and-select-exists.md) | 2026-10-31 | Subqueries in `where_in`/`where_not_in`, `where_not_exists` and `add_select_exists` | Membership, anti-join and per-row exists flags needed `DB.select`: `where_in(col, builder)` raised `'QueryBuilder' object is not iterable` and no method emitted `NOT EXISTS` or an `EXISTS` column. `where_in`/`where_not_in` now take a builder or a callable (like `select_sub`) and render `col IN (subquery)` via `SubSelectExpression`; iterables are unchanged. `where_exists`/`or_where_exists` also take a callable; new `where_not_exists`/`or_where_not_exists`. `add_select_exists(alias, query)` appends `EXISTS(subquery) AS alias` (selecting `{table}.*` first when nothing is selected) through a `SubGroupExpression(exists=True)` and the grammar's `select_exists_string()`; MSSQL renders `CASE WHEN EXISTS(...) THEN 1 ELSE 0 END`. A shared `_resolve_subquery` raises one `TypeError`. Subquery bindings stay bound and land in SQL order. `Model` proxies all. Limits: Postgres returns `bool`, others `1`/`0`; `NOT IN` over a subquery with `NULL` matches nothing. Chosen over raw SQL or separate `*_sub` method names. |
| [061](061-builder-row-locks.md) | 2026-10-31 | Row locks on the query builder: `lock_for_update` and `shared_lock` | The grammars already rendered a per-dialect lock clause, but no builder method set it and `get_grammar()` never passed `lock`, so apps wrote `SELECT ... FOR UPDATE` by hand. `QueryBuilder.lock_for_update()`/`shared_lock()` set `_lock` to `"update"`/`"share"` (last call wins) and `get_grammar()` passes it: Postgres `FOR UPDATE`/`FOR SHARE`, MySQL `FOR UPDATE`/`LOCK IN SHARE MODE`, SQLite a no-op, and MSSQL now `WITH(ROWLOCK, UPDLOCK, HOLDLOCK)`/`WITH(ROWLOCK, HOLDLOCK)` instead of a bare `ROWLOCK` hint. `Model` proxies both. Only selects lock; `new()` groups and the `paginate()` count start unlocked. Chosen over update-first, whose `FOR NO KEY UPDATE` does not wait for an in-flight foreign-key insert, and over a stringly `lock(mode)`. Known limit: Postgres refuses a lock with aggregates, `DISTINCT` or `GROUP BY`. |
| [062](062-update-uses-the-updated-table.md) | 2026-11-01 | `update()` compiles against the updated table | `update()` built its grammar with `self.grammar()`, keeping the default table `users`, so MySQL and MSSQL prefixed every `SET`/`WHERE` column with `users` whatever table was updated (`UPDATE [queues] SET [users].[status] = ?`). It now passes `table=self._table`. Postgres and SQLite do not prefix update columns. `update()` still returns the row count; a queue claim reads locked rows, iterates and updates each by key, as Laravel's database queue does, so no `UPDATE ... RETURNING` is added. |
| [063](063-ai-embeddings.md) | 2026-11-01 | AI embeddings with a Laravel-style fake | The `ai` package had no way to embed text, so apps called vendor SDKs by hand, with no shared config and nothing to fake. `Embeddings.of(inputs).dimensions(n).generate(provider, model)` returns an `EmbeddingsResponse`, using LangChain `init_embeddings` + `aembed_documents`. A per-`Lab` table maps the provider key and dimension option (OpenAI `dimensions`, Google `output_dimensionality`, Bedrock `model_kwargs`). The default comes from `AIConfig.default_embeddings` / `ModelType.EMBEDDINGS`. `Embeddings.fake()` (random unit vectors, a list of responses, or a callable taking `EmbeddingsPrompt`), `prevent_stray_embeddings`, `assert_generated`/`assert_not_generated`/`assert_nothing_generated` and `fake_embedding` mirror Laravel. Chosen over a per-vendor `EmbeddingsFactory` ABC. `Str.of(text).to_embeddings()` returns one vector. `table.vector(col, n)` adds a pgvector `VECTOR(n)` column, `.index()` on a vector column (named or not) and `vector_index()` add an HNSW cosine index, and `schema.ensure_vector_extension_exists()` enables the extension. `select_vector_distance`, `where_vector_distance_less_than`, `order_by_vector_distance` and `where_vector_similar_to` use `<=>` on Postgres, with escaped, table-prefixed identifiers. Generic `list[...]`/`dict[...]` fields cast as JSON. Raw order-by now gets a comma. Caching and queued assertions are deferred. |
5 changes: 5 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .config.ai import AIConfig
from .decorators import max_steps, max_tokens, model, provider, timeout, top_p
from .document import Document
from .embeddings import Embeddings, EmbeddingsFake, EmbeddingsPrompt, EmbeddingsResponse
from .image import Image, ImageResponse
from .image_factory import ImageFactory
from .ai import Ai
Expand All @@ -31,6 +32,10 @@
"AudioFactory",
"BedrockConfig",
"Document",
"Embeddings",
"EmbeddingsFake",
"EmbeddingsPrompt",
"EmbeddingsResponse",
"ElevenLabsConfig",
"GoogleConfig",
"Image",
Expand Down
2 changes: 2 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import TYPE_CHECKING, Any

from .embeddings import Embeddings
from .lab import Lab

if TYPE_CHECKING:
Expand Down Expand Up @@ -45,6 +46,7 @@ def forget(cls, agent: "Agent | str") -> None:
@classmethod
def reset_fakes(cls) -> None:
cls._fakes.clear()
Embeddings.reset_fake()

def get_model_for(
self,
Expand Down
1 change: 1 addition & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/config/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class AIConfig(Config):
default_image: str = env("AI_DEFAULT_IMAGE_PROVIDER", "openai")
default_audio: str = env("AI_DEFAULT_AUDIO_PROVIDER", "openai")
default_transcribe: str = env("AI_DEFAULT_TRANSCRIBE_PROVIDER", "openai")
default_embeddings: str = env("AI_DEFAULT_EMBEDDINGS_PROVIDER", "openai")

providers: dict[str, Config] = field(
default_factory=lambda: {
Expand Down
3 changes: 3 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/ai/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class OpenAIConfig(Config):
default_factory=lambda: {
"default": "gpt-4o",
"default_image": "dall-e-3",
"default_embeddings": "text-embedding-3-small",
}
)

Expand All @@ -51,6 +52,7 @@ class GoogleConfig(Config):
default_factory=lambda: {
"default": "gemini-2.5-flash-lite",
"default_image": "imagen-3.0-generate-002",
"default_embeddings": "gemini-embedding-001",
}
)

Expand Down Expand Up @@ -83,5 +85,6 @@ class BedrockConfig(Config):
models: dict[str, str] = field(
default_factory=lambda: {
"default": os.getenv("AWS_BEDROCK_MODEL") or "us.anthropic.claude-sonnet-4-6",
"default_embeddings": "amazon.titan-embed-text-v2:0",
}
)
Loading
Loading