Both frameworks connect to this engine on the first try and then get three things wrong that the connection test cannot show. This is not a text to SQL tutorial and not a vector search reference.
Measured 2026-08-28 by installing the packages, reading what ships, and running every statement
below against a local Azure SQL Database container (SERVERPROPERTY('EngineEdition') returns 5).
Every argument name was re-checked 2026-09-03 against the LangChain and LlamaIndex reference
documentation. The pinned versions move fast, so re-run the reference file before trusting one.
Reproduce it first, in two minutes
python -m venv .venv && .venv/bin/pip install \
"langchain==1.3.18" "langchain-community==0.4.2" "langchain-classic==1.0.8" \
"langchain-sqlserver==1.0.1" "llama-index-core==0.14.24" "sqlalchemy==2.0.52" "pyodbc==5.3.0"
import os, urllib.parse
from langchain_community.utilities import SQLDatabase
from langchain_community.agent_toolkits.sql.prompt import SQL_PREFIX
odbc = urllib.parse.quote_plus(
"DRIVER={ODBC Driver 18 for SQL Server};SERVER=<host>,<port>;DATABASE=<database>;"
"UID=<user>;PWD=" + os.environ["SQL_PASSWORD"] + ";Encrypt=yes;TrustServerCertificate=yes")
db = SQLDatabase.from_uri(f"mssql+pyodbc:///?odbc_connect={odbc}")
print(db.dialect) # mssql
print(SQL_PREFIX.format(dialect=db.dialect, top_k=10)) # "always limit your query to at most 10"
print(db.get_table_info()) # DDL plus three real rows per table
Three prints, three defects: the dialect is a string from the URL, the prompt tells the model to limit, and the schema tool hands live rows to the model before anyone asked it a question.
The correction
1. The dialect is the string mssql, and the agent prompt still says "limit". It comes from the
URL, not the server, so it is the same for the container and the cloud, and it is interpolated into
a prompt that never learned T-SQL. Section 1.
2. The framework-created vector table cannot take a vector index. langchain-sqlserver 1.0.1
creates it with PRIMARY KEY NONCLUSTERED, so the table is a heap and CREATE VECTOR INDEX is
refused with Msg 42254. The package holds no approximate search path at all, so retrieval is a
full scan for the life of the table, with correct results and latency that only grows. LlamaIndex
has no Azure SQL Database vector store, so an agent installs a neighbour and wires the wrong
database. Section 2.
3. Read-only, table scoping and no-DML are prompt text, not access control. With
include_tables set to one table, the query tool still ran a SELECT against an excluded table and
returned a row; db.run executed an INSERT and a CREATE/DROP pair. Section 3.
| Capability | LangChain | LlamaIndex |
|---|---|---|
| SQL toolkit over an existing schema | SQLDatabaseToolkit, four tools |
NLSQLTableQueryEngine |
| Per-dialect text to SQL prompt | Only on the legacy chain path | None. One template per engine |
Native vector column store |
langchain-sqlserver, first party |
None. Keep retrieval in SQL |
| Approximate vector search | Not implemented | Not applicable |
| Table info includes live rows | Yes, three by default | No, columns and types only |
1. The dialect prompt, and the error that names the wrong token
There are two prompt surfaces in LangChain and the per-dialect fix reached only one of them.
| Surface | What it tells the model | Verdict |
|---|---|---|
SQL_PROMPTS['mssql'] in langchain_classic.chains.sql_database.prompt, used by create_sql_query_chain |
"at most {top_k} results using the TOP clause as per MS SQL" |
Correct |
SQL_PREFIX in langchain_community.agent_toolkits.sql.prompt, the default prefix of create_sql_agent |
"a syntactically correct {dialect} query ... always limit your query to at most {top_k} results" |
Wrong, dialect-blind |
The hub prompt langchain-ai/sql-agent-system-prompt, and the system_prompt written out in LangChain's own SQL agent guide |
The same "always limit your query" wording | Wrong |
DEFAULT_TEXT_TO_SQL_TMPL in llama_index.core.prompts.default_prompts |
{dialect} only. No row cap, no pagination guidance, no per-dialect variant |
Silent |
The fix exists and sits on the chain path current documentation steers people away from.
The failure is loud in the wrong place
Measured through QuerySQLDatabaseTool, which is what the model reads before it retries:
| Query the model wrote | What comes back |
|---|---|
SELECT customer_id, full_name FROM customers LIMIT 10 |
Msg 102, Incorrect syntax near '10' |
SELECT ... FROM customers ORDER BY full_name LIMIT 10 |
Msg 102, Incorrect syntax near 'LIMIT' |
SELECT customer_id FROM customers OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY |
Msg 102, Incorrect syntax near '0' |
Without an ORDER BY, the error names the number and never mentions LIMIT. The reason is
measured, not inferred:
SELECT LIMIT.customer_id FROM dbo.customers LIMIT; -- succeeds, returns every row
T-SQL parses LIMIT as a table alias and then trips over the integer that follows it. A model
handed Incorrect syntax near '10' has no reason to remove a keyword the message does not mention,
so it edits the number, or the column list, and tries again. Two things make that worse:
- The toolkit's
sql_db_query_checkertool, which its own tool description says to call before every query, uses a prompt listingNOT INwith nulls,UNIONversusUNION ALL,BETWEEN, type mismatches, quoting, argument counts, casts and join columns. Dialect pagination is not on the list. The checker passes the query through. QuerySQLDatabaseToolcallsdb.run_no_throw, so the failure never raises in the application. It becomes a string in the model's context and a longer trace.
Replace the prompt. Do not pass the default and hope
prefix must still contain the {dialect} and {top_k} variables, so keep them and put the syntax
instruction in a sentence the dialect string is not carrying:
from langchain_community.agent_toolkits import create_sql_agent
TSQL_PREFIX = """You are an agent designed to interact with an Azure SQL Database ({dialect}).
Write T-SQL. Return at most {top_k} rows with SELECT TOP ({top_k}), which is how this dialect caps
a result set. LIMIT does not exist in T-SQL and the parser reports it as a syntax error on the
number that follows it, not on the keyword. To page, write
ORDER BY <column> OFFSET <n> ROWS FETCH NEXT <m> ROWS ONLY; the ORDER BY is mandatory.
Never issue INSERT, UPDATE, DELETE, DROP or any other DML statement."""
agent = create_sql_agent(llm, db=db, agent_type="tool-calling", prefix=TSQL_PREFIX, top_k=10)
LlamaIndex takes the same correction through text_to_sql_prompt, whose template needs {dialect},
{schema} and {query_str}:
from llama_index.core import PromptTemplate
from llama_index.core.query_engine import NLSQLTableQueryEngine
tsql_tmpl = PromptTemplate(
"Given an input question, write a syntactically correct T-SQL query for Azure SQL Database "
"({dialect}). Cap rows with SELECT TOP (n). LIMIT is not valid T-SQL. Page with "
"ORDER BY <column> OFFSET <n> ROWS FETCH NEXT <m> ROWS ONLY.\n"
"Only use the tables below.\n{schema}\n\nQuestion: {query_str}\nSQLQuery: ")
engine = NLSQLTableQueryEngine(sql_database=lidb, tables=["customers"],
text_to_sql_prompt=tsql_tmpl)
Keep the prompt short. The dialect rules themselves belong in t-sql-correctness, not in a prompt
string that then goes stale.
2. Retrieval, and the index the vector store cannot have
langchain-sqlserver is the supported LangChain vector store for this engine and it does use the
native type. embedding_length is required and is not derived from the embedding function;
distance_strategy defaults to cosine:
from langchain_sqlserver import SQLServer_VectorStore
store = SQLServer_VectorStore(connection_string=f"mssql+pyodbc:///?odbc_connect={odbc}",
embedding_function=embeddings, embedding_length=1536,
table_name="lcv_docs")
The table it then creates, read back off the engine, with the dimension from embedding_length:
CREATE TABLE lcv_docs (
id UNIQUEIDENTIFIER NOT NULL,
custom_id VARCHAR(1000) NULL,
content_metadata json NULL,
content NVARCHAR(max) NOT NULL,
embeddings vector(1536) NOT NULL,
PRIMARY KEY NONCLUSTERED (id) -- this line is the problem
);
PRIMARY KEY NONCLUSTERED leaves the table a heap, and a heap cannot carry a vector index. Add the
clustered index immediately after the store first initialises, before it holds volume, because
adding it later rewrites the table's storage:
CREATE VECTOR INDEX vi_lcv ON dbo.lcv_docs (embeddings) WITH (METRIC='cosine', TYPE='diskann');
-- Msg 42254, Clustered index is required on table 'dbo.lcv_docs' to create a vector index.
CREATE CLUSTERED INDEX ix_lcv_docs_cl ON dbo.lcv_docs (id);
SET QUOTED_IDENTIFIER ON;
CREATE VECTOR INDEX vi_lcv ON dbo.lcv_docs (embeddings) WITH (METRIC='cosine', TYPE='diskann');
-- Msg 42266 once the clustered index clears the first gate: 100 non-null vectors are required.
Msg 42266 is the expected second gate and proves the clustered index fixed the first. Without
QUOTED_IDENTIFIER ON the statement fails with Msg 1934 before reaching either.
The emitted query is the exact-scan shape, and that is not configurable
similarity_search_with_score(k=2) on a vector(8) store, captured off the connection with the
vector literal written out:
SELECT TOP 2 lcv_docs.id, lcv_docs.custom_id, lcv_docs.content_metadata, lcv_docs.content,
VECTOR_DISTANCE('cosine', cast ('[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8]' as vector(8)),
embeddings) AS distance
FROM lcv_docs ORDER BY distance ASC;
TOP and not LIMIT, because SQLAlchemy compiles the row cap for the dialect even when the prompt
does not. But the package contains no VECTOR_SEARCH, no WITH APPROXIMATE and no
CREATE VECTOR INDEX anywhere in its source, so there is no option to turn on. Below roughly tens
of thousands of rows, accept it. Above that, do the retrieval in SQL and keep the framework for
chunking, prompting and orchestration. Read vector-search-azure-sql before writing that query: the
shape that reaches the index is a different statement, not a tuning option.
| Behaviour | Detail |
|---|---|
| A 3072 dimension model | Msg 2717 at construction: the size exceeds the maximum allowed (1998). Decide the dimension budget first, which is rag-on-azure-sql |
embedding_length mismatched to the function |
Msg 42204 at write time: the vector dimensions do not match |
| Metadata filters run inside the search query | WHERE JSON_VALUE(content_metadata, ?) = ?, parameterised, applied before the top-k. Good |
Numeric metadata filters cast to NUMERIC(10, 2) |
{"ts": {"$gt": 1756500000}} returns Msg 8115, arithmetic overflow converting nvarchar to numeric. The cast is on the stored column, so one oversized value poisons every numeric filter on that key, and it throws at read time, never at write. Keep epoch times, large ids and money out of metadata |
| Batch size is capped at 419 | Larger raises before any round trip |
| Auth falls back silently | No Uid/Pwd and no Trusted_Connection=yes switches to DefaultAzureCredential, so a typo in the credential keywords fails as a token acquisition, not as a bad password |
LlamaIndex has no Azure SQL Database vector store
Checked on the package index and in the integrations tree on 2026-08-28. Do not let an agent install
a neighbouring package because the name looks close. The two honest options are to keep retrieval in
T-SQL and feed the rows in as nodes, or to use the SQL toolkit for structured questions and put the
semantic half where this catalog already covers it. Producing the embedding inside the engine is
embeddings-and-external-models.
3. Read-only, no schema dumping, parameterised
Treat all three as things to switch on. None of them is a default.
Read-only is a login, not a setting
Neither framework has a read-only mode. SQLDatabase.run executed an INSERT that committed and a
CREATE TABLE/DROP TABLE pair in one call; LlamaIndex's run_sql executed an UPDATE. The
DO NOT make any DML statements line is a sentence in a prompt, so any instruction reaching the
model through retrieved content competes with it on equal terms. The control is a dedicated
least-privilege login. Measured through the query tool with that login:
| Statement | Result |
|---|---|
SELECT TOP (2) full_name FROM customers |
Rows |
UPDATE customers SET city = N'Owned' WHERE customer_id = 1 |
Msg 229, The UPDATE permission was denied on the object 'customers' |
DROP TABLE customers |
Msg 3701, Cannot drop the table ..., because it does not exist or you do not have permission |
EXEC sp_executesql N'SELECT 1' |
Runs. The tool does not restrict itself to SELECT text either |
Create that login only with the read-only grants shown here, because the grant is the whole guardrail.
include_tables scopes the prompt, not the connection
Worth measuring yourself, because the parameter name suggests otherwise:
db = SQLDatabase.from_uri(uri, include_tables=["customers"], sample_rows_in_table_info=0)
print(db.get_usable_table_names()) # ['customers']
from langchain_community.tools.sql_database.tool import QuerySQLDatabaseTool
print(QuerySQLDatabaseTool(db=db).invoke("SELECT TOP (1) id FROM lcv_docs")) # returns the row
include_tables decides what the model is told about. It does not decide what the connection can
reach. Scope the login to the schema instead.
The schema tool returns live rows by default
sample_rows_in_table_info defaults to 3, so the schema tool returns the CREATE TABLE text
plus three real rows, every column, into the model's context and into whatever stores the trace.
On a customer table that is three real customers.
db = SQLDatabase.from_uri(uri, sample_rows_in_table_info=0) # schema only
db = SQLDatabase.from_uri(uri, custom_table_info={"customers": FAKE_DDL}) # or fake samples
Error text is a second channel: run_no_throw returns the driver message verbatim, database, schema
and object names included, as in Msg 229 and Msg 3701 above.
Parameterised means the parts you write
The framework's own SQL binds its values. The query tool takes one argument, a SQL string the model wrote, so a text to SQL agent is by construction a machine that executes generated SQL and there is no parameterisation to add there; the containment is the login. In the code you write around the framework, bind the values, and never build a query out of a chunk retrieved from the corpus.
Check it worked
Four assertions against the wiring you are about to ship. The first two fail on a default install:
import os, re
from langchain_community.tools.sql_database.tool import QuerySQLDatabaseTool
generated_sql = os.environ["AGENT_SQL"] # one query your agent actually produced
assert "SELECT TOP" in TSQL_PREFIX and "LIMIT does not exist" in TSQL_PREFIX
assert "3 rows from" not in db.get_table_info()
denied = QuerySQLDatabaseTool(db=db).invoke("UPDATE customers SET city = N'x' WHERE 1 = 0")
assert "permission was denied" in denied, denied
assert not re.search(r"\bLIMIT\b", generated_sql, re.I), generated_sql
print("prompt, schema exposure, write permission and generated SQL all check out")
Expected: the script prints that line and exits 0. A failure on the get_table_info assertion means
sample_rows_in_table_info is still 3; a failure on the denied assertion means the agent's login
can write, which is the finding and not a test bug. Then confirm the vector table can carry an index
at all, with the password in SQLCMDPASSWORD:
sqlcmd -S <host>,<port> -d <database> -U <user> -C -Q \
"SELECT type_desc FROM sys.indexes WHERE object_id = OBJECT_ID('dbo.lcv_docs') AND type_desc = 'CLUSTERED';"
Expected: one row reading CLUSTERED. An empty result set means the table is still the heap the
store created, and CREATE VECTOR INDEX on it will return Msg 42254.
Do not
- Do not accept the default SQL agent prompt on this engine. It renders as
mssql, tells the model to limit, and the error it earns names the wrong token. - Do not assume the per-dialect prompt applies to the agent. It exists only on the chain path.
- Do not conclude the query is fine because the checker tool approved it, and do not fix a
LIMITfailure by editing the number the error points at. - Do not let the vector store create its table and then plan on a vector index later. The heap is the blocker and the migration gets more expensive with every row.
- Do not expect approximate vector search from
langchain-sqlserver. There is no code path. - Do not install a LlamaIndex vector store package whose name merely resembles this engine's.
- Do not rely on
include_tablesor on a prompt sentence to stop a write. A login stops a write. - Do not ship with
sample_rows_in_table_infoat its default and call the agent read only. - Do not put an epoch timestamp or a large id in metadata that anything filters on numerically.
- Do not re-teach
VECTOR_DISTANCE,VECTOR_SEARCHor the vector index here. Those arevector-search-azure-sql.
References
- Open references/framework-prompts-and-vector-store.md when a claim above disagrees with what you are seeing, which after a version bump it will: it holds the install, every probe script, the raw engine output and the reproduction steps.
- Read
vector-search-azure-sqlbefore writing the retrieval query yourself, for thevectortype, its restriction list and the query shape that reaches the index rather than scanning. - Read
rag-on-azure-sqlbefore choosingembedding_length, because the dimension budget, the chunk schema and the permission filter are decided there. For offline use, keep embedding application-side and preserve the same schema and permission filter. - Read
t-sql-correctnesswhen the generated SQL is wrong in a way that is not pagination,connect-from-pythonwhen the failure is the driver, the connection string or the token rather than the framework. Apply the read-only grants in section 3 before exposing the connection. - Read LangChain's SQL agent guide to check whether the shipped prompt has changed before trusting section 1, and LlamaIndex structured data for where the prompt argument goes on the query engine.