NLQueries GitHub

Configuration Reference

All settings are read from environment variables, or a .env file in the working directory (copy .env.example to .env to start). Only ./.env is read: a .env in a parent directory is not. A variable already set in the environment wins over the same one in .env.

Variable Required Default Description
ANTHROPIC_API_KEY One of these two, except on Bedrock — Anthropic API key
OPENAI_API_KEY One of these two, except on Bedrock — OpenAI API key
ANTHROPIC_BASE_URL No — Endpoint for the Anthropic client, read by the Anthropic SDK — for a gateway in front of the API. While it is set, the check in Provider and model must agree is skipped. An http:// URL to a host other than localhost or a loopback address logs a warning once per endpoint, since prompts and the API key would cross the network unencrypted; it is not refused.
LLM_MODEL No claude-sonnet-5-5, or openai/gpt-4o when OPENAI_API_KEY selected the provider LLM model identifier
LLM_PROVIDER No Auto-detected anthropic, litellm, or bedrock (which resolves to litellm). Any other value, openai included, fails with Unknown LLM provider when an LLM client is created. Unset, the provider is detected in this order: an LLM_MODEL starting bedrock/ selects Bedrock (see Amazon Bedrock); then ANTHROPIC_API_KEY selects anthropic; then OPENAI_API_KEY selects litellm, with LLM_MODEL defaulting to openai/gpt-4o and LLM_MODEL_FAST to openai/gpt-4o-mini. With both keys set Anthropic wins, so to use OpenAI then, set LLM_PROVIDER=litellm and LLM_MODEL=openai/<model>.
QDRANT_URL No http://localhost:6333 Qdrant URL. Required for --embed, the semantic cache, and document connectors.
QDRANT_API_KEY For any Qdrant not on loopback — Required whenever QDRANT_URL is not on this machine — Qdrant Cloud and the Docker Compose stack included. On this machine means the host is localhost or a loopback address (127.0.0.0/8, ::1); any other host, a DNS name that merely starts with 127., and a URL whose host cannot be read all count as remote. A URL without a scheme (qdrant:6333) is read as http://qdrant:6333, as the Qdrant client reads it. Without the key the Qdrant client refuses to connect rather than talk to a remote vector store anonymously. Set the same key on the Qdrant service (QDRANT__SERVICE__API_KEY). A key sent over plain http to another host is logged as a warning, since it crosses the network unencrypted.
QDRANT_COLLECTION No nlqueries Qdrant collection name
NLQ_STATE_DIR No ~/.nlqueries Root for everything NLQueries keeps between runs. The paths below default under it; set them individually to override. Set this when the home directory is not writable — a read-only root filesystem otherwise stops the embedding server starting, and with it every natural-language query. The Docker image sets it to /data/nlqueries
KB_PATH No ~/.nlqueries/knowledge_base Local path for exported knowledge base files
CONNECTORS_FILE No ~/.nlqueries/connectors.yaml Path to the connector registry
CAPSULES_DIR No ~/.nlqueries/capsules Path to saved query capsules
FEEDBACK_DIR No ~/.nlqueries/feedback Path to feedback JSONL files
NOTION_API_TOKEN Only for Notion sync — Notion integration token
CONFLUENCE_API_TOKEN Only for Confluence sync — The API token for doc-sync-confluence; the site URL and user are its --base-url and --username options
HF_TOKEN No — Hugging Face token — avoids rate limits on the one-time embedding model download. See troubleshooting.md.
EMBED_SERVER_PORT No 8765 The one port setting for the embedding daemon: the port it listens on (embed-server start, python -m nlqueries.embeddings.embed_server) and the port clients, embed-server status and health look for it on. embed-server start --port overrides the listening port and warns when it differs from this, since clients would not find it
NLQ_EMBED_MODEL No all-MiniLM-L6-v2 The sentence-transformers model, by hub name or local path, used by both the daemon and in-process embedding. It must produce 384-dimension vectors; a model of another width is refused
EMBED_SERVER_REQUIRED No false When true, a call the daemon cannot answer fails instead of loading the model in the calling process. Leave it off for the CLI; set it on a long-running server, where the fallback loads the model into that process and keeps it there
EMBED_CLIENT_TIMEOUT_SECONDS No 2.0 How long a client waits for the embedding daemon before giving up on a request
EMBED_SERVER_MAX_CONCURRENCY No min(4, CPU count) How many embeddings the daemon computes at once. 1 serialises them
EMBED_SERVER_TORCH_THREADS No 1 Intra-op thread count for the torch backend inside the daemon. 0 leaves torch's own default
NLQ_LOG_FORMAT No text Log line format of the embedding daemon, text or json, when it runs in the foreground (embed-server start --foreground, or python -m nlqueries.embeddings.embed_server, whose --log-format overrides it). Any other value is treated as text, with a warning
NLQ_AUDIT_LOG No — A file that receives every audit event (authorization decisions and query executions) as one JSON line. Opened append-only for each event and created at mode 0600. A symbolic link at the path is not followed (where the platform supports O_NOFOLLOW), anything but a regular file (a FIFO, a device) is refused without blocking, a file with more than one hard link is refused, a file owned by another user is refused, and on POSIX an existing file that group or others can reach is narrowed to 0600 and refused if that fails. A file that is refused or cannot be written gets no events and is reported on the nlqueries.audit.sink logger with the reason and the number of events it has not received: at the first, and again at the 10th, 100th, 1000th and so on, then once more with the total when writing to it works again. The events still go to nlqueries.audit, and the query is not failed. Unset, the events still go to the nlqueries.audit logger. See Audit events.
OTEL_EXPORTER_OTLP_ENDPOINT No — Not read by NLQueries. Its OpenTelemetry instrumentation installs no tracer provider or exporter of its own, so spans and metrics go nowhere unless the process runs under an OpenTelemetry SDK setup that configures one (for example opentelemetry-instrument), which is what reads this variable. Set on its own, it exports nothing
LLM_MAX_OUTPUT_TOKENS No 4096 Tokens an answer may generate, and the two derived budgets: a SQL correction or candidate set gets half (floor 512), intent classification and follow-up resolution an eighth (floor 200). At the default that is 4096 / 2048 / 512. The classification floor binds until this exceeds 1600, so lowering it to, say, 1536 moves the answer and correction budgets but leaves classification at 200. The default was 1024 until the defaults moved to Claude Sonnet 5.5 and Haiku 5.5, which think before answering when a question calls for it; at 1024 Sonnet 5.5 can run out part-way through its SQL. The allowance is a ceiling: tokens are billed as generated. It does not govern knowledge-base column descriptions, which size their own allowance from the column count (kb_generator), nor the CLI and MCP health checks. Raise it for a reasoning model — such a model bills its private reasoning from the same allowance and spends it first, so a budget sized for the answer alone returns a short answer, or nothing at all on the smallest calls. Producing nothing now raises OutputBudgetExhausted, naming the model and the budget it spent, rather than returning an empty string that is indistinguishable from a model with nothing to say. Follow-up resolution and knowledge-base column descriptions are the exception: they log it at WARNING and carry on with a degraded result, because an unresolved follow-up is still worth asking. A non-positive value is ignored with a warning and the default applies.
LLM_EFFORT No low How hard Claude 5 and later think before answering, sent as output_config.effort: low, medium, high, xhigh or max. Blank sends nothing, so the model uses its own default (high for Sonnet 5.5, medium for Haiku 5.5). An unrecognised value is ignored with a warning and low applies. Sent to Claude only through the Anthropic API (the Anthropic provider, or LiteLLM with an anthropic/ or bare claude- model), and never to an earlier Claude model. Bedrock, Vertex and OpenRouter keep each model's default effort. Claude 5 and later also reject a temperature, so none is sent to them. DeepSeek through LiteLLM (a deepseek/ model) is sent it as reasoning_effort, which takes low, high or max: low and medium send low, high sends high, xhigh and max send max. It goes in extra_body, since LiteLLM turns a top-level reasoning_effort for DeepSeek into thinking on or off and drops the level; none is sent while LLM_THINKING is off. Other models are sent nothing.
LLM_THINKING No on Whether a DeepSeek model (deepseek/ through LiteLLM) thinks before answering: on or off. DeepSeek thinks by default; off sends thinking: {"type": "disabled"} and no LLM_EFFORT. on sends nothing and leaves it to the model. Blank means on; an unrecognised value is ignored with a warning and on applies. Other models are sent nothing either way.
LLM_TIMEOUT_SECONDS No 180 How long an LLM call may go without producing data before it fails. Not a rescue from an unbounded wait: both SDKs already impose 600s of their own (anthropic's DEFAULT_TIMEOUT, litellm's COMPLETION_HTTP_FALLBACK_SECONDS), so what this buys is 600 → 180, configurable, plus a single LLMTimeout to catch in place of one exception type per SDK. It is a read deadline, not a cap on the call: httpx applies it per read, so it fires after this many seconds of silence. On the streaming paths the Anthropic Messages API sends periodic ping events that each reset the clock, so a stream that keeps trickling can outlast it — do not size an upstream budget as though this capped a question. 180 is deliberately generous rather than tight: a reasoning model on a long schema can take a minute or more before its first token, and a deadline that fires on those trades a hang for a broken deployment. Not to be confused with CONNECTOR_STATEMENT_TIMEOUT_SECONDS, which is the budget for the SQL, not for the model. A non-positive, non-finite or unparseable value is ignored with a warning — inf included, since it parses and would hand the SDKs a deadline that never fires.
LLM_MODEL_FAST No claude-haiku-5-5 (Anthropic) / openai/gpt-4o-mini (OpenAI) / same as LLM_MODEL (Bedrock) Smaller/cheaper model used for short-output auxiliary calls (intent classifier, follow-up resolver). Set to any LiteLLM-supported model string. On Bedrock it defaults to your LLM_MODEL rather than to a Haiku id that does not exist there — correct but not cheap, so set a Bedrock Haiku id to get the cheap tier back.
QUERY_HISTORY_LIMIT No 500 Maximum number of useful queries returned by process-history after filtering. Override per-run with --max-queries.
EMBED_BACKEND No torch Embedding backend for the embed-server daemon. torch uses sentence-transformers/PyTorch (no extra deps); onnx uses ONNX Runtime via optimum[onnxruntime] (faster cold start, no PyTorch).
NLQ_SCHEMA_FORMAT No compact Schema format injected into the system prompt. compact uses M-Schema (【Table】 …) — fewer tokens; verbose uses full Markdown (### Table: …) — backward compatible.
NLQ_SELF_CONSISTENCY No off Self-consistency for the LLM repair step, which runs only when the generated SQL fails validation and the mechanical (no-LLM) repair fails too. off — one repair call; hard — three repair candidates in parallel when the repair is judged hard (at least two complexity keywords such as join or group by in the correction request, which includes the failing SQL, or more than 20 tables in the knowledge base), otherwise one call; all — always three candidates. The candidates are normalised and the most frequent one wins, preferring those that validate.
NLQ_CACHE_ANSWER_THRESHOLD No 0.97 Cosine similarity threshold for the Tier 1 answer cache. Questions within this distance of a cached question get the cached answer directly.
NLQ_CACHE_TEMPLATE_THRESHOLD No 0.90 Cosine similarity threshold for the Tier 2 template cache.
NLQ_CACHE_MAX_QUESTION_CHARS No 500 Longest question that may be written to the semantic cache; 0 disables the limit. A question over it is still answered normally, just not stored. Long questions are near-unique, so they cost a write and are rarely hit again — and a padded prompt injection has exactly that shape.
NLQ_CACHE_ANSWER_TIERS No 0,1,2 Which cache tiers may serve an answer. 0 is an exact match on the normalised question; 1 is cosine similarity over stored answers; 2 binds the question's entities into a stored SQL template. Set to 0 for a sensitive agent to keep exact-match caching without the tiers that can serve one user's answer to another user's differently-worded question.
NLQ_CACHE_COSINE_CANDIDATES No 5 How many neighbours the Tier 1 and Tier 2 searches fetch. The caller's cache context is matched inside the query, so these candidates already belong to the caller; the window exists because a candidate can still fail signature verification, the TTL, or entity binding. Raising it helps only where those failures are common.
NLQ_CACHE_PRUNE_INTERVAL_SECONDS No 3600 How often a cache collection is swept for points past the TTL; 0 disables it. Nothing else deletes from the cache — the TTL is applied on read — so without a sweep a collection grows for the life of the agent, and expired points go on consuming the NLQ_CACHE_COSINE_CANDIDATES slots a lookup scans. The sweep runs on write, at most once per collection per interval per process — so a long-lived server sweeps hourly, while a CLI invocation sweeps once. The delete is issued without waiting, and created_at is indexed as a datetime on collections created from now on, so the work is a range query rather than a scan.
NLQ_LITERAL_GROUNDING No true Before a generated statement runs, check each string literal it compares a column to (=, IN, LIKE without a wildcard) against the values the column stores. A literal that exists as written is left. One that matches exactly one stored value ignoring case and surrounding spaces, or failing that also ignoring punctuation at either end (.,;:!?'"()[]{}-, for literals of three characters or more once that is removed), is replaced with it, so status = 'legal' runs as status = 'Legal'; the substitution is recorded in the answer's provenance as literals_grounded. One that matches none, or several, is left, and if the statement goes on to an LLM repair, the repair is told which values are nearby. Only text columns the knowledge base knows, never keys, never a column whose name marks it as personal data (the names export-kb refuses to sample: names containing password, passwd, pwd, secret, token, hash, salt, ssn, credential, api key, access key, private key, signing key, encryption key, ssh key, master key, license or licence key, credit card, card number, cvv, iban, account number, national id, passport, tax id, dob, birth, email, phone, address, first, last, full, given or family name, session, cookie or auth (but not author or authority), authorization or authorisation, otp at the start of a word (user_otp, not footprint), totp or hotp at the start of a word with no letter after (totp_code, not hotpot), account no not followed by a letter (not account_notes), and the whole word pin (card_pin, not shipping); bare key, name and account, and a _key suffix, do not count, so keyword, product_name and a join key such as customer_key are not refused for their name), and literals up to 100 characters. Where the knowledge base holds a column's complete value list (values_complete, stored by export-kb), the list answers: a literal in it needs no query, and one that is not gets a single exact-match query, in case it was stored since the export, before the list supplies the match or the nearby values; the list was read with no row restriction, so under a per-request row filter the lookups go through the filter instead. A table the knowledge base records as larger than NLQ_LITERAL_GROUNDING_MAX_ROWS is not queried. Read-only and bounded per pass: at most 8 literals, 1 s a lookup, 3 s in all, and a lookup still running at 3 s is abandoned rather than waited for. A statement gets at most two passes, one before a repair and one on the repaired statement, so up to 16 literals and 6 s of lookups; outcomes are cached per database for 15 minutes, and only across requests no row-restricting wrapper separates; any error skips the check. Runs whatever the execution policy, for any agent with a registered connector, reading through the agent's connector (open_connector_for_agent, so a per-request wrapper such as a row filter applies) with read-only permission; the generated statement is never run by it. Set false to turn it off.
NLQ_LITERAL_GROUNDING_MAX_ROWS No 1000000 Literal grounding sends no query to a table the knowledge base records as having more rows than this. Without a stored value list, a literal that matches nothing costs up to four queries, most of them scans: slow on a large table, and billed on a warehouse. A column of such a table whose complete value list the knowledge base holds is still grounded, from the list alone, where no row filter applies. A table whose size the knowledge base does not record is queried. 0 removes the cap. The size is the one the connector reports, and the generic SQLAlchemy connector (MySQL, MariaDB, Oracle and the rest) reports none, so on those engines this cap skips nothing; nor do the others report one in every case (a Postgres table never analysed, a Redshift role without access to SVV_TABLE_INFO). export-kb says how many tables report no row count.
NLQ_LITERAL_GROUNDING_REPAIR No true When literal grounding leaves a valid statement with a literal no stored value matches, and offers nearby values for it, make one LLM call with the same cached system prompt, the question, the statement and grounding's notes, asking for the same statement with only the literal corrected if a stored value clearly matches the question. The answer replaces the statement only if it validates and differs from it in nothing but the values of the literals grounding flagged, those no stored value matches or several do: a changed column, join, aggregate or number, an added or removed predicate, or any other literal changed, such as a date format or a pattern elsewhere in the statement, keeps the original. The replacement is then grounded again, with no second call. Never loops. Recorded in the answer's provenance as literal_repair (attempted, changed, notes, and reason, which is rejected: non-literal change when the answer changed more than literals and rejected: changed a literal grounding did not flag when it changed another literal); the call's tokens are counted like any other. Notes with no nearby values make no call. Only meaningful when NLQ_LITERAL_GROUNDING is on. Set false to turn it off.
NLQ_GLOSSARY_QUESTION_SCOPED No false When true, glossary terms are injected per question — only terms the question mentions, plus their hierarchy ancestors and descendants (depth 3) — instead of the whole glossary in the cached static prompt. Business rules are always injected in full. Off by default (the full glossary ships in the static block, exactly as before).
NLQ_SEND_RESULT_ROWS_TO_LLM No true Whether executed result rows go to the LLM when a hybrid (SQL plus documents) answer is synthesised. true: up to 20 rows are sent, with every column whose name marks it as personal data (the names export-kb refuses to sample, listed under NLQ_LITERAL_GROUNDING) left out and named as withheld. That check reads the column name the query returns, not the column it came from, so SELECT email AS contact is not caught and its values are sent. false: the synthesis prompt gets the column names and the row count only, and no value from any row; this is the setting that holds whatever the columns are called. While values are withheld (the setting off, or every column withheld), a failed query is reported only as having failed, without the database's error text, since a driver's error can quote a value (Duplicate entry 'bob@example.com'). When no query ran (generate-only mode, a refused or failed execution, a statement that failed validation), the generated statement is sent whatever this says: it is not data. The result table the reader sees is the same either way. See What leaves this machine.
NLQ_MAX_DOCUMENT_EXPANDED_BYTES No 134217728 (128 MiB) Largest total uncompressed size permitted in a zip-based document (.xlsx, .docx). An upload is size-limited and a request body is capped, but neither bounds what comes out of a parser, and the size on disk says nothing about it: measured on ordinary content, 200k rows by 10 columns is 5.2 MiB on disk and 108.8 MiB expanded. Parsing costs a multiple of the expanded size (a .docx of 250 MiB of ordinary paragraphs peaked at about 2 GiB), so at 128 MiB an ordinary document fits within NLQ_DOCUMENT_PARSE_MEMORY_BYTES; raise the two together. Also the largest .txt or .md file ingested. Read from the zip directory before any parsing, so nothing is decompressed to find out. A malformed value falls back to the default rather than failing the ingest.
NLQ_MAX_DOCUMENT_EXPANSION_RATIO No 100 Largest expanded-over-compressed ratio permitted in a zip-based document, checked for the archive as a whole and, for any member that expands to 4 MiB or more, for that member on its own. Complementary to the byte ceiling rather than redundant with it: a small file that expands 1000× is a bomb even when the result fits under the ceiling, and a large file that expands 2× is ordinary. Checked per member as well because one member that expands 1000× beside a large incompressible one (an image, say) averages out under the limit for the whole archive. Smaller members are exempt from the per-member check because the risk is the expanded size: a few MiB costs little memory however well it compressed, and ordinary documents have such members (20,000 identical Word paragraphs give a document.xml of about 4 MB at about 250×). 100× is roughly three times the highest ratio measured on real content.
NLQ_MAX_DOCUMENT_ROWS No 50000 Most spreadsheet rows extracted from one document, across all its sheets. The expansion limits above read what a zip archive declares about itself; this is counted while reading, so it bounds a document whatever its directory claims. Measured: 200,000 rows by 10 columns took 87 seconds to ingest from a 5.2 MiB file. At the connector's batch size of 50, 50,000 rows is already a thousand chunks from one document.
NLQ_MAX_EXTRACTION_SECONDS No 120 Wall-clock budget for extracting one document, in every connector. The general bound on cost: per-format counts are guesses about it, and a 3,000-page PDF of scanned images and a 30-page one of dense tables differ by orders of magnitude. The count caps (NLQ_MAX_PDF_BYTES, NLQ_MAX_PDF_PAGES, NLQ_MAX_DOCUMENT_ROWS) do not replace it; they refuse what is known to be too large before the work starts. Checked between units of work (a page, every 1,000 Word paragraphs, a section, a batch of rows), so it bounds a long document rather than interrupting one slow page. A ceiling on background ingestion, not a latency target. For PDF, Word and Excel, which parse in a child process (NLQ_DOCUMENT_PARSE_ISOLATION), the child is also killed when this budget plus 15 seconds of start-up has passed, which does stop a parser stuck inside one page.
NLQ_DOCUMENT_PARSE_ISOLATION No true Parse PDF, Word and Excel documents in a child process, which is killed at the extraction deadline and runs under NLQ_DOCUMENT_PARSE_MEMORY_BYTES and NLQ_DOCUMENT_PARSE_CPU_SECONDS. A document that runs out of memory is refused as too complex; one stopped by the deadline or the CPU limit raises the same timeout as the in-process budget. A child whose caller is gone stops itself at the extraction deadline rather than at once; on Linux and macOS it runs in a session of its own, so a signal sent to the caller's process group (a supervisor's kill -TERM -<pgid>, a terminal hang-up) does not reach it. Set false only where starting a process per document is not possible; the in-process limits still apply then.
NLQ_DOCUMENT_PARSE_MEMORY_BYTES No 2147483648 (2 GiB) Memory limit for the child that parses a document: an address-space limit (RLIMIT_AS) on Linux and macOS, which counts virtual memory, the interpreter and its libraries included, and a Job Object process memory limit on Windows, which counts committed memory. A lower hard limit already set on the process (ulimit -v) is kept. A container's memory limit is not one: it is enforced by the kernel, which kills the child when the container as a whole reaches it. That is reported as an extraction timeout, which a caller may retry, since the document may not be the cause. Raise it together with NLQ_MAX_DOCUMENT_EXPANDED_BYTES.
NLQ_DOCUMENT_PARSE_CPU_SECONDS No 180 CPU-time limit for that child: RLIMIT_CPU on Linux and macOS, the Job Object's user-mode CPU-time limit on Windows (the child and what it starts). Above NLQ_MAX_EXTRACTION_SECONDS, which normally ends a slow parse first.
NLQ_MAX_PDF_BYTES No 104857600 (100 MiB) Largest PDF file, on disk, that is ingested. A PDF is not a zip, so the expansion limits never see it; its size is known before anything is parsed, so an oversized file is refused before the parser opens it. Raises DocumentTooComplexError.
NLQ_MAX_PDF_PAGES No 2000 Most pages in one PDF that are extracted. Read from the page count the document declares before any page is extracted, and counted again before extraction since a malformed file can misstate it. A longer document is refused with DocumentTooComplexError; split it and ingest the parts.
NLQ_MAX_CONFLUENCE_PAGES No 5000 Most pages one doc-sync-confluence run reads from a space. A sync stores what it read as the whole space and removes the rest, so a space over the cap is refused, with nothing stored, rather than read in part.
NLQ_MAX_CONFLUENCE_BYTES No 209715200 (200 MiB) Most bytes of page body (HTML storage format, UTF-8) one doc-sync-confluence run reads. Counted as each page body arrives, so a space of a few very large pages is bounded too. Refused rather than truncated, as above.
NLQ_MAX_NOTION_BLOCKS No 10000 Most blocks read from one Notion page by doc-sync-notion, counting every block, text or not. A page over the cap is refused, with nothing stored, rather than stored in part.
NLQ_MAX_NOTION_BYTES No 52428800 (50 MiB) Most bytes of block text (UTF-8) read from one Notion page by doc-sync-notion. The block cap bounds how many blocks are read, not how much text each carries, so a page of a few thousand very large blocks would pass it; this counts the text kept from each block as it is read. A page over the cap is refused, with nothing stored, rather than stored in part.
NLQ_CACHE_SIGNING_KEY Where more than one process shares a cache Generated Key that signs semantic-cache entries; an entry that does not verify is treated as a miss. Unset, a key is generated and written to cache_signing_key under NLQ_STATE_DIR, or held in memory for the life of the process if that cannot be written. Every process that shares a cache must use the same key, so a deployment with more than one (separate containers, say) must set this or NLQ_CACHE_SIGNING_KEY_FILE. A key shorter than 32 bytes, from any source, stops the MCP server at startup; elsewhere it disables the cache (every lookup misses) and is logged as an error. Generate one with openssl rand -hex 32
NLQ_CACHE_SIGNING_KEY_FILE No — Path to a file holding the signing key, for mounted secrets. Used when NLQ_CACHE_SIGNING_KEY is not set; a file that cannot be read, or is empty, is an error rather than a fallback
NLQ_CACHE_ENCRYPTION No false Encrypt cached answers and the text of document chunks with AES-256-GCM before storing them in Qdrant. Governs writing only: text stored before it was turned on is still read, and encrypted text is decrypted whenever a key is available, with the setting on or off. Vectors, questions, SQL, document names and chunk metadata are stored as before. See qdrant-setup.md
NLQ_CACHE_ENCRYPTION_KEY Where more than one process writes to the same Qdrant Generated Key for NLQ_CACHE_ENCRYPTION. Use a random one, such as the output of openssl rand -hex 32: the AES key is derived from it with HKDF, which does nothing to slow guessing, so a short or chosen key can be guessed offline against a copy of Qdrant's data. A configured key under 32 characters is warned about. Unset, a key is generated on the first encrypted write and kept in cache_encryption_key under NLQ_STATE_DIR, created at mode 0600. If it cannot be written the write fails, rather than encrypting with a key that would be lost on restart. Every process that shares a Qdrant must use the same key. Encrypted text that cannot be decrypted is a cache miss or a skipped chunk, with one warning per process for each of the two
NLQ_CACHE_ENCRYPTION_KEY_FILE No — Path to a file holding that key, for mounted secrets. Used when NLQ_CACHE_ENCRYPTION_KEY is not set; a file that cannot be read, or is empty, is an error rather than a fallback
CONNECTOR_STATEMENT_TIMEOUT_SECONDS No 120 Statement timeout for a query that does not pass its own, so a slow query fails instead of hanging. 0 disables it. How each connector enforces it, and which cannot, is in database-hardening.md
CONNECTOR_MAX_FETCH_ROWS No 10000 Most rows a connector materialises from one query — a bound on memory, not the number of rows an answer shows
CONNECTOR_MAX_RESULT_BYTES No 67108864 (64 MiB) Approximate memory ceiling for one result set; whichever of this and CONNECTOR_MAX_FETCH_ROWS binds first stops the read
CONNECTOR_CACHE_ENABLED No true Reuse open connectors across queries instead of building one per query. false builds one per query
CONNECTOR_CACHE_TTL_SECONDS No 900 How long a cached connector is reused before it is rebuilt
CONNECTOR_CACHE_MAX_ENTRIES No 32 How many connectors the cache keeps open; the least recently used is evicted first
CONNECTOR_POOL_SIZE No 5 Connections each PostgreSQL connector keeps open
CONNECTOR_MAX_OVERFLOW No 5 Extra connections a PostgreSQL connector may open under burst and then discard. Each cached connector can hold up to CONNECTOR_POOL_SIZE + CONNECTOR_MAX_OVERFLOW connections; check that against the database's own connection limit before raising either
REDSHIFT_SOCKET_TIMEOUT_SECONDS No CONNECTOR_STATEMENT_TIMEOUT_SECONDS + 30, at least 60 (150 by default; 0 when the statement timeout is 0) Socket timeout for a Redshift connection, which bounds every read, not just the connect. Keep it above the statement timeout; see connectors.md
SNOWFLAKE_QUEUE_TIMEOUT_SECONDS No 30 How much of a Snowflake query's statement timeout it may spend waiting in the warehouse queue before Snowflake cancels it, capped at half the timeout; the rest bounds how long it runs, so the two together stay within CONNECTOR_STATEMENT_TIMEOUT_SECONDS. Raise it for a warehouse that queues under load or resumes from suspension.
BIGQUERY_MAX_BYTES_BILLED No unset (no cap) Most bytes one BigQuery query job may bill, set on every job that answers a question as maximum_bytes_billed. BigQuery fails a job that would bill more before it runs, and the answer reports the error. Schema and query-history reads are not capped. A value that is not a whole number greater than zero makes every BigQuery query fail with an error naming the variable, rather than run without the cap; other commands, health and doctor included, still run; see connectors.md
MCP_TRANSPORT No stdio (the Docker image: sse) Transport for python -m nlqueries.mcp_server and the Docker image: stdio, sse or streamable-http. nlqueries mcp-server start takes --sse instead
MCP_HOST No 127.0.0.1 (the Docker image: 0.0.0.0) Bind address for a networked transport, for python -m nlqueries.mcp_server and the Docker image. A wildcard address is refused unless NLQ_ALLOW_INSECURE_BIND=1; mcp-server start takes --host
MCP_PORT No 8000 (the Docker image: 8080) Port for a networked transport, for python -m nlqueries.mcp_server and the Docker image; mcp-server start takes --port
NLQ_MCP_MAX_QUESTION_CHARS No 4000 Longest question an MCP tool accepts, in characters. A longer one is refused before the tool runs. Not a positive whole number: the default, with a warning. Applies on every MCP transport; see mcp-authentication.md
NLQ_MCP_MAX_SQL_CHARS No 8000 Longest SQL an MCP tool accepts (submit_feedback's generated_sql and corrected_sql), in characters, with the same rules
NLQ_MCP_*, NLQ_ALLOW_UNAUTHENTICATED_MCP, NLQ_ALLOW_INSECURE_BIND For a networked MCP transport — Authentication, grants, limits and the bind override for the MCP server; a networked transport will not start without authentication. See mcp-authentication.md

Provider and model must agree

LLM_PROVIDER selects a client and LLM_MODEL names a model, and the two can disagree. Where they do and the request would leave for the wrong vendor, it is refused before anything is sent.

AnthropicClient does not inspect the model id, so an Anthropic provider with LLM_MODEL=deepseek/deepseek-chat would transmit the system prompt, the schema and the question to api.anthropic.com and only then report the model missing — the data has gone by the time the error arrives. That combination now raises at the first LLM call, naming both settings. The same applies to a bedrock/ model reached through any other provider.

anthropic/claude-sonnet-5-5 is accepted and treated as claude-sonnet-5-5. The prefixed spelling is the form LiteLLM documents and a reasonable thing to write; passing it through unchanged had the same shape of failure, since no Anthropic model id contains a slash.

Two cases are deliberately not refused:

  • A bare model name. deepseek-chat names no provider in its own text, and deciding which one it belongs to needs LiteLLM's model registry — DeepSeek's keys are spelled bare there, Mistral's prefixed. This path does not consult it. If you are running a non-Anthropic model, either give it its provider prefix or set LLM_PROVIDER=litellm.
  • A gateway. When api_base or ANTHROPIC_BASE_URL points the client somewhere other than api.anthropic.com, the check is skipped: the premise no longer holds and your gateway may expect prefixed ids.

A gateway URL, from ANTHROPIC_BASE_URL or an api_base override for either client, that uses http:// to a host other than localhost or an IP address that is loopback (127.0.0.0/8, ::1) logs one warning per endpoint per process. The request still goes out: an in-cluster gateway on plain http is a legitimate setup. Hostnames are not resolved, so a name that points at 127.0.0.1 still warns. The warning shows only the scheme, host and port, never credentials in the URL.

Amazon Bedrock

Bedrock is reached through LiteLLM, so there is no separate provider to install. Set LLM_MODEL to a bedrock/ model id and NLQueries routes there:

LLM_MODEL=bedrock/us.anthropic.claude-sonnet-4-6
AWS_REGION=us-east-1

The region never fails loudly, so set it deliberately. LiteLLM takes the first of these that it finds: a region passed in code, one embedded in a model ARN, AWS_REGION_NAME, AWS_REGION, then whatever a boto3.Session() resolves (AWS_DEFAULT_REGION, or a profile in ~/.aws/config). If none of them produce a region it does not raise — it falls back to a built-in us-west-2. A deployment that forgot the region therefore calls a real region it never chose, where the model is very likely not enabled, and the error says the model was not found. AWS_REGION above is honoured; AWS_REGION_NAME beats it if both are set.

Authentication is boto3's, not an API key. NLQueries passes no AWS credentials of its own, so the ordinary chain applies: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN for temporary credentials) in the environment, a shared ~/.aws/config profile, or — on EC2, ECS or EKS — the instance profile, task role, or IRSA role of the host. On a machine that already has an AWS role, no credential settings are needed at all.

Three things to check when it does not work, because each fails differently:

  • The model must be enabled for your account in that region, under Bedrock → Model access in the AWS console. Until it is, every call returns AccessDenied however correct the credentials are.
  • The region must match where the model is enabled. A model id from one region simply does not exist in another.
  • Newer Anthropic models require a cross-region inference profile, which is the us. or eu. prefix inside the id — bedrock/us.anthropic.claude-…, not bedrock/anthropic.claude-…. The bare id is rejected as not found.

The IAM role needs bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream on the model or inference-profile ARN.

The CLI understands this too: health, process-history --annotate and export-kb --describe-columns treat a Bedrock configuration as a credential and do not ask for an API key. health still makes a real call, so it reports whether the role can actually invoke the model rather than only whether one is configured.

LLM_PROVIDER=bedrock also resolves to LiteLLM, but it does not replace the model id: naming the provider does not choose a model, and the default LLM_MODEL is an Anthropic id that LiteLLM would route to Anthropic. So LLM_PROVIDER=bedrock requires an LLM_MODEL beginning bedrock/, and without one the first LLM call fails with a message saying so. It fails there rather than at startup deliberately — connect, extract-schema and the diagnostics you would run to find the problem never touch an LLM, and should still work while you are fixing it. Setting only the model works on its own and is the shorter path.

The model decides, wherever it is set. A bedrock/ id selects Bedrock even when nothing names a provider — before the request is built, not at the API. That matters because the Anthropic client does not reject a bedrock/ model id: it would send the system prompt, the schema and the question to api.anthropic.com and only then report that the model does not exist. For a deployment that chose Bedrock to keep traffic inside its AWS account, the data would already have left. Explicitly naming a provider other than Bedrock alongside a bedrock/ model is refused as the contradiction it is, whether it was named in LLM_PROVIDER or by a host application.

Set LLM_MODEL_FAST too, or unset it. Short auxiliary calls — intent classification and follow-up resolution — use the fast tier, and they carry the user's question and the conversation history. On a Bedrock deployment a LLM_MODEL_FAST that is not a bedrock/ id is refused, because it would otherwise be routed to whichever provider it does name, sending exactly that content outside your AWS account while the main model kept working. A .env carrying LLM_MODEL_FAST=claude-haiku-4-5-20251001 from a previous Anthropic setup is the usual way to arrive here. Leaving it unset is fine: it then follows LLM_MODEL.

The same ordering applies to key-based detection: the bedrock/ prefix is checked before ANTHROPIC_API_KEY, so a Bedrock deployment that still has an Anthropic key in its environment for something else keeps going to Bedrock.


Windows note: ~ in default paths resolves to C:\Users\<YourUsername> in PowerShell. To set a variable for the current session use $env:VAR = "value"; to persist it, use System Properties → Environment Variables or add it to your PowerShell profile.


Audit events

Two events go to the nlqueries.audit logger, and to NLQ_AUDIT_LOG when it is set: authorization, one per decision the MCP guard makes (see What gets recorded), and query_execution, one per statement sent to a database for an agent. Each is a JSON object with sorted keys, rendered on one line: control characters and non-ASCII text are escaped, so no field can break a record across lines. The log message is the event's words (authorization allowed, query executed) followed by that JSON, and the record is attached as extra={"audit": ...} as well.

Every event carries:

Field Meaning
schema_version 1. Raised when a field is renamed, removed or changes meaning; adding a field does not raise it, so ignore fields you do not know.
event authorization or query_execution
request_id 32 hex characters. One per MCP tool call, shared by its decision and the statements it ran; one per CLI invocation. A caller that uses the package as a library gets a new one per event.
surface mcp, cli or library
at When it happened, ISO 8601 in UTC

A query_execution event adds:

Field Meaning
principal, source The caller's subject and how it was established (oidc, static-token, local, anonymous), or null when not known: the library binds none
agent_id The agent the statement ran for
connector_type The connector's registered type, such as postgres or sqlite
dialect The SQL grammar of that database, as the SQL policy names it (tsql for SQL Server); null when it cannot be told
sql_sha256 SHA-256 of the statement's UTF-8 bytes, the same digest the SQL policy records for a decision
sql_bytes The statement's length in UTF-8 bytes
executed_sql_sha256, executed_sql_bytes Present only when a connector wrapper rewrote the statement before it reached the database (a row filter's predicate, say) and reported the text it sent as QueryResult.executed_sql: the digest and length of that text. Absent when the statement ran as asked.
policy_version The SQL policy's version
policy_decision allow or deny: what the SQL policy decides about this statement for this dialect. It is recorded, not enforced, at this point; null when there is no dialect to decide for
outcome ok; error when the database returned an error; exception when the call raised, a refusal to execute included
row_count, truncated As the connector reported them; null when the call raised
duration_ms Wall-clock time of the call
error_class The exception's class name when the call raised, else null. Never its message, which can quote the statement.

An event never carries the question, the SQL text, the rows or a credential. Recording never fails a query: if an execution event cannot be built or published, that is logged on nlqueries.audit.sink with its traceback at WARNING the first time a cause (the exception's type and the line that raised it) is seen, and at DEBUG after that. Every statement run through the connector an agent's query opens is recorded, whether it comes from a fresh question, a cache replay or literal grounding's value lookups. Statements the CLI runs on a connector it opens itself (the health check's SELECT 1, export-kb's table sampling) are not.


What leaves this machine

Every LLM call sends text to the provider set by LLM_PROVIDER. What it carries:

Data Sent Notes
The question On every question With the last few conversation turns when the question reads as a follow-up
Schema On every SQL question Table and column names, types, keys, and descriptions, each description on one line and capped at 300 characters; business rules and glossary definitions on one line and capped at 1,000 (a rule or definition cut at the cap is logged once, as a warning naming its start)
Sample values On every SQL question Up to five stored values per column (all of them where the knowledge base holds the complete list), each quoted; a value over 80 characters is left out, not cut, and a complete list that loses one is shown as samples. Never for a column whose name marks it as personal data
Result rows On the hybrid path only Up to 20 rows, personal-data columns withheld by the name the query returns them under, so a renamed one (SELECT email AS contact) is sent (the row count is still sent when every column is withheld); with NLQ_SEND_RESULT_ROWS_TO_LLM=false, the column names and the row count only, which is the control that holds whatever a column is called. While values are withheld, a failure is reported without its error text. When no query ran, the generated statement instead. A SQL-only answer sends no rows
Document excerpts On document and hybrid questions Each excerpt in a delimited block with its source label. Only the block markers inside it are neutralised; the rest of the text, &, < and > included, is sent as written

Descriptions, sample values, glossary entries, example queries and excerpts are framed in the prompt as reference data that never changes its instructions. Up to 1,000 characters of a database error go back to the model when it corrects a failed statement, and literal grounding can send nearby stored values to its repair step (see NLQ_LITERAL_GROUNDING_REPAIR).

Document and hybrid answers are not written to the semantic cache, so an answer built from a document is never replayed after that document has changed. Entries written by an earlier version expire with the TTL.


Docker Compose services and volumes

Running docker compose up from the repository root starts:

Service Port Purpose
qdrant 6333 (REST), 6334 (gRPC) Vector store for embeddings, semantic cache, document search
nlqueries-core 8080 MCP server (requires NLQ_MCP_STATIC_TOKEN, sent as a bearer token) + CLI engine

Both services also require QDRANT_API_KEY in .env: Qdrant is started with it as its API key and the app uses it to connect, and docker compose up refuses to start while it or NLQ_MCP_STATIC_TOKEN is unset. The ports are published on 127.0.0.1 unless BIND_ADDR says otherwise.

Volume Persists
qdrant-data Qdrant collections across restarts
nlqueries-data Knowledge bases, connector config, capsules, and feedback (mounted at /data/nlqueries in the container)

Run CLI commands inside the container with docker exec -it nlqueries-core nlqueries <command> — when connecting to a database on your host machine, use --host host.docker.internal.