Skip to content

Add AI embeddings, Str.to_embeddings and pgvector queries - #347

Merged
tmgbedu merged 8 commits into
mainfrom
task/ai-embeddings
Nov 2, 2026
Merged

tmgbedu merged 8 commits into
mainfrom
task/ai-embeddings

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds text embeddings to the ai package, following Laravel's AI SDK Embeddings and Testing Embeddings. Design in ADR 063.

from fastapi_startkit.ai import Embeddings

response = await Embeddings.of(["Napa Valley has great wine.", "Laravel is a PHP framework."]).generate()
response.embeddings  # [[0.123, ...], [0.789, ...]]

response = await Embeddings.of(["..."]).dimensions(1536).generate("openai", "text-embedding-3-small")
  • Uses LangChain init_embeddings and aembed_documents. Supported providers: OpenAI, Google (google_genai) and Bedrock. dimensions() maps to dimensions, output_dimensionality or model_kwargs={"dimensions": n}. Anthropic and ElevenLabs raise ValueError.
  • New AIConfig.default_embeddings (AI_DEFAULT_EMBEDDINGS_PROVIDER, default openai) and ModelType.EMBEDDINGS. Default models: text-embedding-3-small, gemini-embedding-001, amazon.titan-embed-text-v2:0.
  • of() replaces Laravel's for, which is a Python keyword. Image.of and Audio.of use the same name.

Testing API

Embeddings.fake()                                   # random unit vectors, prompt dimensions or 1536
Embeddings.fake([[vector_a], [vector_b]])           # one response per call
Embeddings.fake(lambda prompt: [Embeddings.fake_embedding(prompt.dimensions) for _ in prompt.inputs])
Embeddings.fake().prevent_stray_embeddings()

Embeddings.assert_generated(lambda p: p.contains("Laravel") and p.dimensions == 1536)
Embeddings.assert_not_generated("Other")            # callable or string
Embeddings.assert_nothing_generated()
Embeddings.reset_fake()                             # test cleanup, like Ai.reset_fakes()

Str.to_embeddings and pgvector

vector = await Str.of("Napa Valley has great wine.").to_embeddings()   # faked by Embeddings.fake()

# migration
await schema.ensure_vector_extension_exists()              # CREATE EXTENSION IF NOT EXISTS vector
async with await schema.create("documents") as table:
    table.vector("embedding", dimensions=1536).index()     # VECTOR(1536) + HNSW vector_cosine_ops

class Document(Model):
    embedding: list[float]

await Document.where_vector_similar_to("embedding", vector, min_similarity=0.4).limit(10).get()
await Document.select_vector_distance("embedding", vector).order_by_vector_distance("embedding", vector).get()
await Document.where_vector_distance_less_than("embedding", vector, 0.3).get()
  • These are PostgreSQL only and use cosine distance <=>. Other platforms and grammars raise ValueError. Vectors are bound as JSON arrays, the same way Laravel does it.
  • Passing text in place of a vector raises TypeError because the query builder is sync. Embed it first with await Str.of(q).to_embeddings().
  • Fix: model fields annotated list[...]/dict[...] now get JsonCast. Before, they fell back to the string cast, so vectors came back as strings.
  • Fix: order_by(...).order_by_raw(...) was missing the comma between terms.

Not included (follow-ups)

  • Embedding caching (->cache()).
  • Queued assertions: the package has no queued generation for any modality.
  • The docs site page.

Test plan

  • tests/ai/test_embeddings.py: provider options and dimensions per provider, config defaults and overrides, unsupported provider, all three fake modes, prevent stray, and assertions. embeddings.py has 100% coverage.
  • Checked the init_embeddings options against the real langchain-openai, langchain-google-genai and langchain-aws classes on Python 3.13.
  • Vector grammar, schema, caster and to_embeddings unit tests.
  • tests/masoniteorm/postgres/test_postgres_vector.py passes against a live pgvector/pgvector:pg18 database, along with the rest of the Postgres suite (43 passed). CI's Postgres service now uses pgvector/pgvector:pg18 (was postgres:17) so the extension is available.
  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3854 passed, coverage 92.99%. ruff and basedpyright are clean.

🤖 Generated with Claude Code

tmgbedu and others added 2 commits November 1, 2026 14:40
Embeddings.of(inputs).dimensions(n).generate(provider, model) embeds text
through LangChain init_embeddings for OpenAI, Google and Bedrock, with the
default provider and model from AIConfig.default_embeddings.

Embeddings.fake() supports random unit vectors, a list of responses or a
callable, plus prevent_stray_embeddings, assert_generated,
assert_not_generated, assert_nothing_generated and fake_embedding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.06977% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...t/src/fastapi_startkit/masoniteorm/models/model.py 84.61% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

- Stringable.to_embeddings() embeds one string through Embeddings
- Blueprint.vector(col, n) adds VECTOR(n); .index() builds an HNSW cosine index
- Schema.ensure_vector_extension_exists() enables pgvector
- select_vector_distance, where_vector_distance_less_than,
  order_by_vector_distance and where_vector_similar_to on Postgres
- Generic list[...]/dict[...] model fields now cast as JSON
- Separate a raw ORDER BY from preceding order terms with a comma

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tmgbedu tmgbedu changed the title AI: embeddings with Embeddings.fake() and assertions Add AI embeddings, Str.to_embeddings and pgvector queries Nov 1, 2026
The vector tests need the pgvector extension, which the plain postgres image lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature overall: the embeddings API, the fake and the ADR are clean, and the grammar tests pin binding order. Tests: 3854 passed with 93% coverage (--ignore=tests/masoniteorm/postgres). The full postgres suite (43, including the 4 new pgvector tests) passes against pgvector/pgvector:pg18. No comment or docstring violations, and ADR 063 is indexed.

Verdict: REQUEST CHANGES (posted as a comment because GitHub blocks request-changes from the PR author account). Blocking issues (see the inline comments):

  1. builder.py:705 — vector identifiers are quoted by hand and embedded " is not escaped. select_vector_distance('emb" <=> ?) AS x FROM pg_shadow --', v) compiles to SELECT ... ("emb" <=> ?) AS x FROM pg_shadow --" .... Column and alias names reach raw SQL unescaped. Use the grammar's own column/table wrapping, as every other builder method does, or at least double any embedded ". Please add a test.
  2. Blueprint.py:818 — only a bare .index() creates HNSW. table.index("embedding") with an explicit name creates a btree index on the vector column. I checked this live: with vector(1536) and a btree index, every INSERT fails with index row size exceeds btree maximum. Pick the index type from the column's type whatever way the column is named, or add an explicit vector_index(). Please add a test.

Nits (non-blocking):

  • caster.py:318: a non-nullable embedding: list[float] set to None is stored as the JSON string 'null', which pgvector rejects (Vector contents must start with "["). Consider passing None through in JsonCast.set, or documenting that callers should use list[float] | None.
  • Ai.reset_fakes() does not clear Embeddings._faked, so a forgotten reset_fake() leaks the fake into later tests. Consider having reset_fakes clear it too.
  • Vector column refs are not table-prefixed ("embedding"), while other where clauses are ("documents"."a"). This could be ambiguous in joins unless the caller qualifies the column.

def _vector_column(self, column: str) -> str:
if not self.grammar.supports_vectors:
raise ValueError(f"{self.grammar.__name__} does not support vector queries; use PostgreSQL with pgvector.")
return ".".join(f'"{part}"' for part in column.split("."))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embedded " is not escaped, so a column or as_ such as 'emb" <=> ?) AS x FROM pg_shadow --' breaks out of the identifier. Please use the grammar's wrapping helpers, like the other builder methods, or escape " as "", and add a test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7ee1747. Column and alias names now go through PostgresGrammar.wrap_identifier, which doubles any embedded ". Your payload compiles to ("documents"."emb"" <=> ?) AS x FROM pg_shadow --" <=> ?), so it stays one quoted identifier. The regression test is test_vector_identifiers_escape_embedded_quotes and covers both the column and as_. The same commit also adds the table prefix from the nit: unqualified columns become "documents"."embedding", or "d"."embedding" when the query has an alias.

"""
index_type = "index"
if not column:
index_type = "vector" if self._column().column_type == "vector" else "index"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only picks HNSW when index() is called with no column. table.index("embedding") falls through to a btree index. I checked on pgvector pg18: a btree index on vector(1536) makes every INSERT fail with index row size exceeds btree maximum. Please choose the index type from the column type whichever way the column is passed, and add a test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68282d4. index() now picks the index type from the column type. If any indexed column was added as vector in the same blueprint, it creates an HNSW vector_cosine_ops index, whether the column is named or taken from the last column. For an existing vector column that the blueprint cannot see (an alter that only adds an index), there is now an explicit table.vector_index(column=None, name=None), which raises on non-Postgres platforms. Tests: test_named_index_on_a_vector_column_creates_an_hnsw_index, test_named_index_on_a_regular_column_stays_a_btree_index, test_vector_index_on_an_existing_column and test_vector_indexes_require_postgres. The old alter test that pinned the btree output now expects HNSW.

tmgbedu and others added 3 commits November 1, 2026 17:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Ai.reset_fakes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tmgbedu

tmgbedu commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All items are addressed in 7ee1747, 68282d4 and aa13437:

  • Identifier escaping (blocking): wrap_identifier doubles ", and columns are now table- or alias-prefixed (this also covers the join-ambiguity nit).
  • HNSW for named vector columns (blocking): index() picks the index type from the column type, and vector_index() handles existing columns.
  • JsonCast.set(None): now returns None (SQL NULL) instead of null. This matches the other casts. The existing test that pinned "null" for a bare dict was updated.
  • Ai.reset_fakes(): now also clears the Embeddings fake, with a test.
  • ADR 063 and its index entry are updated.

Tests: 3865 passed, 93% coverage (--ignore=tests/masoniteorm/postgres). tests/masoniteorm/postgres 43 passed against a throwaway pgvector/pgvector:pg18 container.

@tmgbedu tmgbedu left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 7ee1747, 68282d4 and aa13437 at aa13437d.

Verdict: APPROVE. Posted as a comment because the author account cannot approve its own PR. Both blocking items are fixed and tested. The notes below don't block the merge.

Tests

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3865 passed, 10 skipped, coverage 93.00%.
  • tests/masoniteorm/postgres against a throwaway pgvector/pgvector:pg18: 43 passed.
  • I also ran live probes against that database (listed below).

Items from the earlier review

  1. Identifier escaping: fixed. wrap_identifier doubles ". Live, the payload emb" <=> ?) AS x FROM pg_shadow -- stays inside one quoted identifier, as the column and as as_, and nothing runs from it. The regression test is test_vector_identifiers_escape_embedded_quotes.
  2. HNSW for named .index(): fixed. Live, table.index("embedding", name=...) on vector(1536) creates USING hnsw (embedding vector_cosine_ops), and a 1536-dim insert succeeds. table.vector_index("embedding") in an alter also creates HNSW, and it raises on SQLite, MySQL and MSSQL. All of this is tested.
  3. JsonCast.set(None) returns NULL: safe, but it changes behaviour. Nullable fields already skipped the cast, so only non-nullable dict/list fields are affected. A nullable column now stores SQL NULL instead of JSON null, so where_null matches and reads are unchanged, because get already maps 'null' to None. A NOT NULL JSON column given None now raises an IntegrityError where it used to store 'null' without complaint. This undoes what test_bare_annotations_keep_their_none_behaviour pinned in dc7324c, and ADR 063 documents it. Please call it out in the release notes.
  4. Ai.reset_fakes() clears the embeddings fake: fixed. The test is test_ai_reset_fakes_clears_the_embeddings_fake.
  5. Table prefix: fixed. Checked live:
    • alias: "d"."embedding";
    • schema-qualified table: "public"."documents"."embedding";
    • joins: an unqualified column takes the base table, a qualified one like chunks.embedding is kept;
    • alias + self-join runs.

ADR 063 and its index entry are updated. No new comments or docstrings were added.

Non-blocking notes

  • Blueprint.py:817: table.index(["embedding", "title"]) becomes USING hnsw (embedding,title vector_cosine_ops). pgvector rejects this with access method "hnsw" does not support multicolumn indexes. Btree was already broken for this case, so it is not a regression. Consider raising a clear ValueError when a vector column is in a multi-column index.
  • builder.py:206: select_vector_distance on a schema-qualified table with no explicit select() crashes with ValueError: too many values to unpack. The cause is the existing add_select_raw → public.documents.* → _table_column_string path, so it is not new code, but vector queries now reach it. With an explicit .select(...) it works. Worth a follow-up.
  • This one was already there and fails safely: the ?→$n converter also rewrites a ? inside a quoted identifier. The injection payload therefore ends in a syntax error and never executes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tmgbedu
tmgbedu merged commit fed63f1 into main Nov 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant