- name
- rag-on-azure-sql
- description
- Builds retrieval augmented generation end to end on Azure SQL Database: chunking source text, storing embeddings with the provenance that makes them re-runnable, retrieving with the filter and the permission check inside the same query, and grounding an answer on what came back. Use when someone asks to "build RAG on Azure SQL Database", "chat with my documents", "add semantic search over my data", "keep embeddings in sync when rows change", "re-embed with a new model", or "which chunks should I put in the prompt"; and when a retrieval pipeline returns plausible but wrong context, or returns text the asking user is not allowed to read. This skill owns the pipeline and the schema around it. The vector type, VECTOR_DISTANCE and the query shape are vector-search-azure-sql, and the in-database embedding call is embeddings-and-external-models. Offline local embedding is an application-side workflow.
# Retrieval augmented generation on Azure SQL Database
What tables exist, what is written when, what the retrieval query has to carry, and how to prove it
worked.
Verified 2026-08-28 against a live Azure SQL Database, General Purpose serverless, compatibility
level 170, and re-run 2026-09-03, which is where `Msg 37579` comes from. Numbers not from those
runs are from Microsoft Learn, linked at the end.
Open [references/pipeline.md](references/pipeline.md) when you sit down to write the ingest or the
re-embed: it holds the `MERGE`, the embedding loop, and what the two runs disagreed about.
## What this owns
The tables, the ingest, what the retrieval query carries, and the grounding. Open
`vector-search-azure-sql` before writing any similarity query, for the `vector` type's restrictions
and the shape that reaches the index; `embeddings-and-external-models` before steps 3 and 4, for
the external model and the chunking and embedding functions; `rls-multi-tenant` for predicates,
with step 6's caveat. When no cloud endpoint is available, keep embedding application-side and
validate the production query and security behavior against Azure SQL Database before release.
## The correction
An agent asked to build RAG here ports a vector store recipe: one table of chunk text with one
embedding column, retrieved by the application's identity, top k pasted into the prompt. It works
on the first demo. Two things are then wrong and **neither raises an error**.
**1. The chunk table is a second copy of the source text.** Whatever protected the original does
not follow the copy. Row level security on `dbo.documents` does not apply to `dbo.document_chunks`,
and neither do its grants, its tenant column or its soft delete flag. A retrieval that is otherwise
perfect puts text the asking user may not read into a prompt, and the model quotes it back. The
obvious fix is refused: a security policy cannot reference a table carrying a vector index,
`Msg 37579`, so the chunk table cannot have a predicate at all and the retrieval query's `WHERE`
clause is the whole boundary.
**2. Nothing records which model produced a stored vector.** A `vector(n)` column carries a
dimension and a base type and nothing else. The two most widely used embedding models both emit
1536 dimensions, so re-embedding half a corpus with another inserts cleanly and leaves two
incompatible geometries in one column. Distances across them are meaningless, no statement
fails, and the only symptom is that answers get worse.
## 1. Fix the dimension before the schema
The dimension follows the model, the ceiling is 1998, and it cannot be widened by `ALTER COLUMN`
even on an empty table. A 3072 dimension model does not fit and has to be asked for a shorter
vector at generation time. Write the number twice, as `vector(n)` and as the dimension
requested from the model; when they disagree the insert fails, the one loud failure here.
## 2. The schema
Two tables. Source rows stay authoritative and chunks are derived.
```sql
CREATE TABLE dbo.documents (
document_id INT NOT NULL IDENTITY PRIMARY KEY,
tenant_id INT NOT NULL,
title NVARCHAR(400) NOT NULL,
body NVARCHAR(MAX) NOT NULL,
updated_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE TABLE dbo.document_chunks (
chunk_id INT NOT NULL IDENTITY PRIMARY KEY, -- clustered; the index needs one
document_id INT NOT NULL REFERENCES dbo.documents (document_id),
tenant_id INT NOT NULL, -- carried, not joined for, step 6
ordinal INT NOT NULL,
chunk_text NVARCHAR(MAX) NOT NULL,
source_hash BINARY(32) NOT NULL, -- HASHBYTES over chunk_text
embed_model SYSNAME NOT NULL, -- provenance
embed_dims SMALLINT NOT NULL,
embedded_at DATETIME2(3) NULL, -- NULL exactly when embedding is
embedding VECTOR(1536) NULL
);
CREATE UNIQUE INDEX ux_chunks_identity ON dbo.document_chunks (document_id, ordinal);
CREATE INDEX ix_chunks_tenant ON dbo.document_chunks (tenant_id); -- the retrieval filter
CREATE INDEX ix_chunks_pending ON dbo.document_chunks (chunk_id)
WHERE embedded_at IS NULL; -- the work queue
```
Four things are load bearing:
- **`chunk_id` is a clustered primary key.** Without a clustered index the vector index cannot be
created at all, and that is discovered late.
- **`source_hash` is how an ingest becomes idempotent.** There is no equality operator on a vector,
so a hash of the text is the only deduplication key. `HASHBYTES` takes the whole `nvarchar(max)`
chunk here, with no 8,000 byte limit.
- **`embed_model` and `embed_dims` are the provenance** failure 2 needs. They are the difference
between re-embedding what changed and re-embedding everything because nobody knows what is in
there.
- **`tenant_id` is carried on the chunk**, deliberately duplicated, so the retrieval filter needs
no join. It is not backed by a predicate here and cannot be. Step 6.
`embedded_at` is NULL exactly when `embedding` is, because step 4 writes both in one statement,
which is what lets the work queue be a filtered index on an ordinary column.
## 3. Chunk
Chunk in the database when the text is already there, in the application when it arrives from
files. The chunking function and its size and overlap parameters are
`embeddings-and-external-models`.
- **Chunk the same way every time.** Changing chunk size later invalidates every stored embedding
as surely as changing the model.
- **Keep the ordinal.** Grounding often needs a hit's neighbours, a cheap seek on
`(document_id, ordinal)` and impossible without it.
- **Store enough text to be an answer.** A chunk too small retrieves well and grounds badly.
## 4. Embed in restartable batches
The model call is `embeddings-and-external-models`. The pipeline shape:
```sql
UPDATE TOP (200) c
SET embedding = AI_GENERATE_EMBEDDINGS(c.chunk_text USE MODEL text_embedder),
embed_model = N'text-embedding-3-small',
embed_dims = 1536,
embedded_at = SYSUTCDATETIME()
FROM dbo.document_chunks AS c
WHERE c.embedded_at IS NULL;
```
Loop until it affects zero rows. The filtered index makes that predicate a seek over a corpus that
is mostly done, where a set based update over the whole table re-embeds finished work and costs
money. Write the vector and its three provenance columns in the **same** statement: a vector
without provenance is what failure 2 is made of. It restarts safely because the identity is
`(document_id, ordinal)`.
## 5. The index goes in after the load
```sql
CREATE VECTOR INDEX vi_document_chunks
ON dbo.document_chunks (embedding)
WITH (METRIC = 'cosine', TYPE = 'diskann');
```
- **At least 100 rows carrying a non-null vector**, `Msg 42266` below that. So it belongs to the
load, never the schema deployment: a package import creates objects before it loads rows. Drop
the index before an export, create it after.
- **The metric is fixed at build time** and the retrieval query has to name the same one.
- **Everything works before the index exists**, exactly and slowly, which is why an environment
runs without one and only latency says so.
- **`TRUNCATE TABLE` is refused once it exists**, `Msg 42232`. Drop, truncate, reload, recreate.
## 6. Retrieve with the filter in the query
One query does the similarity, the tenant filter and the ordering the prompt needs.
```sql
DECLARE @q VECTOR(1536) =
AI_GENERATE_EMBEDDINGS(N'the question' USE MODEL text_embedder);
SELECT chunk_id, document_id, ordinal, chunk_text, distance -- reading order, for the prompt
FROM (
SELECT TOP (8) WITH APPROXIMATE
c.chunk_id, c.document_id, c.ordinal, c.chunk_text, r.distance
FROM VECTOR_SEARCH(
TABLE = dbo.document_chunks AS c,
COLUMN = embedding,
SIMILAR_TO = @q,
METRIC = 'cosine'
) AS r
WHERE c.tenant_id = @tenant_id -- applied DURING the search, see below
ORDER BY r.distance
) AS hits
ORDER BY document_id, ordinal;
```
- **Embed the question with the model that embedded the corpus.** A question embedded by a
different model returns results ranked by nothing.
- **The filter belongs inside, not after.** On a current index version the predicate is applied
during the search, so a filtered top eight returns eight whenever eight qualify. On an earlier
index version the search runs before any predicate: the eight nearest are chosen first and the
filter then removes some, returning three, or none, with qualifying rows unread. Check 3 says
which one you have.
- **The inner `ORDER BY` takes the distance column ascending and nothing else.** Adding
`c.ordinal` fails with `Msg 42271` and dropping it fails with `Msg 42248`, which is why reading
order is applied in the outer query.
- **No policy can back that `WHERE` clause up.** `CREATE SECURITY POLICY` on a table carrying a
vector index is refused, `Msg 37579`, measured 2026-09-03 and documented nowhere on Microsoft
Learn. Keep the policy on `dbo.documents`, where it still guards direct reads, and treat the
filter above as security code: test that removing it changes the result. If a predicate on the
retrieval path is non-negotiable, the sourced ways out are to drop the vector index, since
`VECTOR_SEARCH` still runs as a brute force scan without one, or to split the table so the index
sits on an embeddings table carrying no policy and the protected text joins back, costing the
filter its place inside the search.
- **Retrieve more than you ground on** and re-rank the candidates, but never substitute a bigger k
for a missing filter.
## 7. Ground the answer
- **Pass the chunk text, the document identifier and the ordinal** so the answer can cite it and a
human can check the citation.
- **Give the model permission to say the context does not contain the answer.** A pipeline that
always answers is a hallucination pipeline with extra steps.
- **Do not paste the distance in as a confidence.** It is a geometric distance, not a probability,
and its scale differs by metric.
- **Log the retrieved chunk identifiers with the answer.** When a wrong answer is reported, the
only useful question is what was retrieved, and nothing else answers it later.
## 8. Keep it current
**When a source row changes**, delete its chunks and re-chunk it: boundaries move when text
changes, so updating in place leaves orphans. `MERGE` on `(document_id, ordinal)` does it, and is
supported on a table carrying a current vector index, which maintains itself. The statement is in
the reference.
**When the model or the chunking changes**, everything is stale at once. Add a new column with its
own provenance, backfill fully while retrieval still runs on the old, cut over, then drop the old
column and index. A half migrated column is failure 2. Rebuild the vector index afterwards: the
graph was built for the old distribution and recall degrades with no error.
## Check it worked
Four checks. The second decides whether the retrieval is safe to expose.
```sql
-- 1. Provenance: one model across the corpus, and nothing left unembedded.
SELECT embed_model, embed_dims, COUNT(*) AS chunks,
SUM(CASE WHEN embedding IS NULL THEN 1 ELSE 0 END) AS unembedded
FROM dbo.document_chunks
GROUP BY embed_model, embed_dims;
-- 2. Which tables have a predicate and which have a vector index. Never both.
SELECT t.name AS table_name,
MAX(CASE WHEN sp.target_object_id IS NULL THEN 0 ELSE 1 END) AS has_policy,
MAX(CASE WHEN vi.object_id IS NULL THEN 0 ELSE 1 END) AS has_vector_index
FROM sys.tables AS t
LEFT JOIN sys.security_predicates AS sp ON sp.target_object_id = t.object_id
LEFT JOIN sys.vector_indexes AS vi ON vi.object_id = t.object_id
GROUP BY t.name;
-- 3. The vector index exists and is the current version.
SELECT OBJECT_NAME(v.object_id) AS table_name, i.name AS index_name,
JSON_VALUE(v.build_parameters, '$.Version') AS index_version
FROM sys.vector_indexes AS v
JOIN sys.indexes AS i ON v.object_id = i.object_id AND v.index_id = i.index_id;
-- 4. Every stored hash still agrees with the text it was taken from.
SELECT COUNT(*) AS hashes_disagreeing_with_their_text
FROM dbo.document_chunks
WHERE source_hash <> HASHBYTES('SHA2_256', chunk_text);
```
Run them non-interactively and keep the output:
```bash
sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U <user> -C -b -m-1 \
-i check-rag-pipeline.sql -o check-rag-pipeline.out
```
Expected: check 1 returns **exactly one row** with `unembedded` `0`; two rows means two geometries
in one column and every distance across them meaningless, and a non-zero `unembedded` means part of
the corpus is invisible. Check 2 shows `documents` with `has_policy` `1`, and `document_chunks` with
`has_vector_index` `1` and `has_policy` `0`; that zero is the engine's rule, not an omission to fix,
which is why step 6's `WHERE` clause is what needs the test. Check 3 returns one row per vector
index with `index_version` `3`; empty means every retrieval is scanning, and an older version means
step 6's filter runs after the search rather than during it. Check 4 returns `0`; anything else
means the deduplication key is wrong and re-runs are re-embedding unchanged text.
`-m-1` makes a severity 10 message print its number, and `-b` only sets a non-zero exit at severity
11 and above, so `sqlcmd` exiting 0 says nothing about the four. Read the output file.
**`-m-1` is an ODBC `sqlcmd` instruction**, meaning the 18.x build from `mssql-tools18` or the
Microsoft command line utilities. Measured 2026-09-05, go-sqlcmd 1.10.0, the 1.x build
`brew install sqlcmd` and `winget install sqlcmd` install, prints no `Msg` header on a severity 10
message at any `-m` value, so on that build a severity 10 message arrives with no number to read.
Query `sys.messages` or use the ODBC build when the number matters.
## Do not
- Do not assume the copy inherits the source's protection, and do not try to give it a policy of
its own: that is refused. The query's filter is the boundary and needs a test that fails without
it, in the database rather than in application code.
- Do not store an embedding without recording which model made it, and do not mix two models'
vectors in one column. Nothing will tell you.
- Do not embed the question with a different model from the corpus.
- Do not re-embed rows that have not changed. Hash the text and skip them.
- Do not treat a distance as a confidence score, and do not put it in the prompt.
- Do not create the vector index as part of a schema-only deployment, and do not ship a pipeline
that cannot say "the retrieved context does not answer this".
## References
- [references/pipeline.md](references/pipeline.md): open it while writing step 2 onwards, or when a
claim above disagrees with what you are seeing. It has the idempotent `MERGE`, the embedding
loop, the mixed corpus checks, and the `Msg 37579` disagreement in full.
- [VECTOR_SEARCH](https://learn.microsoft.com/sql/t-sql/functions/vector-search-transact-sql): read
when a filtered retrieval returns fewer rows than asked for, for iterative versus post filtering,
and for the `ORDER BY` rules behind `Msg 42248` and `Msg 42271`.
- [CREATE VECTOR INDEX limitations](https://learn.microsoft.com/sql/t-sql/statements/create-vector-index-transact-sql#limitations-and-considerations):
read before promising a policy on an indexed table. As of 2026-09-03 it lists partitioning,
`TRUNCATE` and package import, and not `Msg 37579`.
- [Row level security](https://learn.microsoft.com/sql/relational-databases/security/row-level-security):
read before putting a predicate on `dbo.documents`.
- [HASHBYTES](https://learn.microsoft.com/sql/t-sql/functions/hashbytes-transact-sql): read before
choosing a deduplication key, for the algorithms still supported and the input limit.
Ver en GitHub