flyquery turns an opaque blob of bytes into a typed, described, embedded
schema in 10 deterministic stages. Format is decided by file extension first
(magic bytes only as a tiebreaker), .gz / .bz2 / .zip wrappers are
stripped recursively, and each format hands off to a dedicated reader that
materialises the data into snappy-compressed Parquet under the dataset's
object-store prefix. Type inference is DuckDB's for CSV / TSV / JSON,
native for Parquet / Avro / ORC / Arrow, and a custom section-aware
walk for XLSX / XLS / ODS (where one sheet is often N tables). When the
header looks fake (DuckDB fell back to column00, or the first row is a
number), a column-name proposer agent rewrites the names in place before
any downstream stage runs. Snapshot diffing detects added / removed / type-
changed columns plus position-and-type renames, with an LLM tiebreaker for
ambiguous cases that surfaces as RENAMED_CANDIDATE for human review.
"Just parse it" works for files that ML papers use as examples. Real-world uploads break that assumption in five ways flyquery has to handle:
| Problem | Real example flyquery has seen |
|---|---|
| Merged-cell title banners | Orbis / BvD financial exports: rows 0-2 are merged-cell branding, the actual header is row 3 or 4. A naive read_csv returns a 1-column table whose values are the title strings. |
| Multi-section spreadsheets | A single Excel sheet contains a "Key figures" box, a "Financials per year" table, a "Contact" card, and a "Regulatory codes" footer — each with its own column count and section label. Treating the sheet as one flat table produces synthetic columnNN headers and mostly-NULL cells. |
| Encoded vs literal delimiters | A CSV with "Smith, John" in a single quoted cell, or a TSV that someone exported with semicolons. The sniffer has to look at the first 4-8 KB to decide. |
| Ambiguous JSON root | data.json could be a single object (one row), an array (N rows), or an object whose values are arrays (N tables). |
| Renames vs net-new columns | A column called customer_id disappears on re-upload and cust_id appears with the same type, position, and sample values. Did the data engineer rename it, or was the old column genuinely deleted and a new one added? |
Every stage below exists because of one of those five problems.
The pipeline as called by IngestService.ingest_upload (synchronous path):
1. receive -> stages/receive.py
2. parse -> stages/parse.py
3. reconcile -> stages/reconcile.py
4. sample -> stages/sample.py
5. profile -> stages/profile.py
7. describe -> stages/describe.py
9. embed -> stages/embed.py
10. publish -> stages/publish.py
Async-deferred (handled by IngestWorker, not on the critical path):
6. relations -> stages/relations.py
8. pii_tag -> stages/pii_tag.py
Stage numbers 6 and 8 are skipped in the synchronous path on purpose —
see ingest_service.py:128-131: "Stages 6 (relations), 8 (pii_tag)
intentionally deferred to the async worker; they're not on the critical
path for the first NL query against the table."
Source: src/flyquery/core/services/ingestion/format_detect.py
The detection function signature is:
def detect_format(filename: str, head_bytes: bytes) -> tuple[str, str]:
# returns (format, compression)Extension always wins when one of the recognised suffixes matches. Magic bytes are a fallback only. The order is documented in the file header: "Inspects the file extension first, then falls back to magic-byte sniffing for files without a recognised extension."
This matters because:
- A Parquet file renamed to
.datwill still be detected via thePAR1magic. - A CSV file with a
.datextension will fail format detection (no text-format magic byte is checked — the{/[branch only catches obvious JSON). - An XLSX with a
.xlsx.tmpextension will likely be mis-detected as the.tmpextension is unknown, so we fall back to magic bytes — XLSX is a ZIP archive so we'll getPK\x03\x04and detect it asxlsx(which is what the operator usually wanted).
Three compression wrappers are detected from the filename suffix only:
| Suffix | Compression code | Inner-format detection |
|---|---|---|
.gz |
gz |
strip 3 chars and re-detect from the inner name |
.bz2 |
bz2 |
strip 4 chars and re-detect |
.zip |
zip |
strip 4 chars and re-detect; zip must contain exactly one file |
The .zip case enforces exactly one file inside (compression.py:55):
files = [n for n in zf.namelist() if not n.endswith("/")]
if len(files) != 1:
raise ValueError(
f"zip must contain exactly one file; got multiple files: {files}"
)Decompressed size is also bounded — MAX_DECOMPRESSED_BYTES = 5 GB
(compression.py:15). A zip-bomb is rejected mid-stream.
Magic-byte recognisers in priority order (format_detect.py:56-70):
PAR1 -> parquet
Obj\x01 -> avro
ORC -> orc
ARROW1 / ARROW -> arrow
PK\x03\x04 -> xlsx (ZIP-based; .ods uses the same magic)
{ or [ -> json
ZIP-based formats (xlsx, ods, plain .zip) all start with
PK\x03\x04. The .zip extension is processed before reaching the
magic-byte fallback, so a bare PK\x03\x04 magic is unambiguously
treated as xlsx. This means an ODS file with a wrong extension
(no .ods suffix) will be mis-detected as xlsx — operators should
rename the file or use the .ods suffix explicitly.
If both the extension AND the magic byte recogniser miss, the function raises:
raise ValueError(f"could not detect format for {name!r}")That bubbles up as a 400 from the upload controller. There is no "try them all" mode — operators must rename the file or pass the correct extension.
The dispatch lives in src/flyquery/core/services/ingestion/reader_factory.py:16:
csv, tsv -> CsvReader
xlsx, xls, ods -> ExcelReader
json, jsonl -> JsonReader
parquet -> ParquetReader
avro -> AvroReader
orc -> OrcReader
arrow, feather -> ArrowReader
Every reader implements the FileReader protocol from reader.py:53:
async def enumerate_tables(source_path, rules) -> list[ProposedTable]
async def materialise(source_path, table, target_parquet_key, *,
workspace_locale, type_infer_sample_rows,
max_title_rows=3) -> MaterialiseResultenumerate_tables is cheap (sample first 4 KB / read schema header);
materialise does the full conversion to Parquet.
Source: src/flyquery/core/services/ingestion/readers/csv_reader.py
flyquery delegates everything to DuckDB's read_csv_auto:
SELECT * FROM read_csv_auto(
'{path}',
sample_size={type_infer_sample_rows}, -- default 8192
auto_detect=true,
ignore_errors=false,
dateformat='%d/%m/%Y' -- locale-dependent; see below
)DuckDB auto-detects:
- Delimiter: tries
,,;,\t,|over the firstsample_sizerows. - Header: presence is inferred from whether the first row's values look type-distinct from the rest (a row of all-strings followed by numeric rows is a header).
- Quoting:
"quote with""escape is the default. - Encoding: UTF-8 with replacement on invalid bytes.
enumerate_tables only counts columns and estimates rows from the byte
size (csv_reader.py:68):
est = max(1, byte_size // 80) # ~80 bytes per row rough guessThe actual row count is computed during materialise after writing to
Parquet.
_date_format_for_locale (csv_reader.py:122-143) returns %d/%m/%Y
for the European locales en-GB, fr, es, de, it, pt, nl,
and None for everything else.
The rationale (verbatim from the source): DuckDB's default heuristic
prefers MM/DD interpretation when day ≤ 12, so 01/02/2026 becomes
Feb 1 in en-US and Jan 2 in en-GB. The hint is only applied for
unambiguously-European locales — adding it on en-US would force
2026-01-15 (ISO) to widen from DATE to TIMESTAMP and degrade
schema quality.
Source: src/flyquery/core/services/ingestion/readers/excel_reader.py
The Excel reader is the most ambitious code path in the entire codebase.
It uses python-calamine (fast Rust-backed reader) to load the workbook,
then runs a section extraction pass over each sheet's rows.
A single sheet becomes N ProposedTables when it looks like a dashboard.
The walk classifies every row as one of:
| Row pattern | Classification | Action |
|---|---|---|
| 0 non-empty cells | empty | empty-run counter; ≥2 closes a section |
1 non-empty cell, type str |
section label | becomes name of next section |
| 1 non-empty cell, not a string | data | continue collecting |
| ≥ 2 non-empty cells | section header | start a new section |
Two consecutive empty rows OR another single-cell-string row closes the current section.
Column count is the union of populated indices across the entire
section (excel_reader.py:163-167), not just the header row. This
matters because merged-cell dashboards put real values at sparse
column indices like [18, 32, 51, 70, 91] and a header-only count
would underreport. The materialise step then compacts those columns
to consecutive positions so the resulting Parquet has dense schema.
_enumerate_sync (excel_reader.py:187-248) emits one ProposedTable
per section. Naming:
- Single-section sheet that covers the whole usable area: table is
named after the sheet (
Orders,Customers). This is the classic "one tab per table" XLSX and stays backward-compatible with naive layouts. - Multi-section sheet: each section's table is named
<sheet>__<section_label>, with_2,_3, ... suffixes for collisions inside the same sheet.
The sheet_or_json_path field is encoded as
<sheet_name>#section[<header_row>:<data_end>] so the section's row
span is preserved across stages. _parse_section_path reverses this on
materialise.
_materialise_sync (excel_reader.py:263-395) does:
- Slice the section's row range.
- Compact populated columns to consecutive positions (drop the empty columns between merged-cell gaps).
- Flatten embedded newlines in cell strings to a single space (Excel
cells can contain multi-line addresses; embedded
\nbreaks DuckDB's CSV dialect sniffer even inside double-quoted cells). - Write a temporary CSV (UTF-8, comma-delimited,
"quoted). - Hand the temp CSV to DuckDB with explicit dialect (no sniffing):
read_csv_auto('{tmp}',
sample_size={type_infer_sample_rows},
auto_detect=true,
ignore_errors=true,
null_padding=true,
delim=',', quote='"', escape='"',
max_line_size=10000000)The explicit delim / quote is critical for single-column sections
— DuckDB's dialect sniffer needs ≥2 columns to distinguish , from
; / \t / |, and a section with one column (e.g. an
"Industria y actividades" two-cell row) would otherwise fail. max_line_size=10MB
covers paragraph-length description cells that the default 2 MB cap
would reject.
When sheet_or_json_path is just a bare sheet name (no #section[...]
suffix), the reader falls back to a simpler heuristic (excel_reader.py:288-296):
body_start = 0
for i in range(min(max_title_rows, len(rows))):
non_empty = sum(1 for c in rows[i] if c not in (None, ""))
if non_empty <= 1:
body_start = i + 1
else:
break
section_rows = rows[body_start:]This skips up to max_title_rows (default 3) of merged-cell title
banners before treating the rest of the sheet as a flat table.
Source: src/flyquery/core/services/ingestion/readers/json_reader.py
JSONL detection has two strict triggers:
- The file extension is
.jsonlor.ndjson. - The first 1 KB contains multiple lines, all of which start with
{when whitespace-trimmed.
Otherwise the file is treated as a single JSON document.
For a regular .json file:
| Root type | Behavior |
|---|---|
list |
One table, n_rows_estimate = len(doc). |
dict where any value is a list |
One table per such key. Optional rules.json_paths allowlist filters which keys to extract. |
dict with no list values |
Falls through to "one 1-row table containing this object's fields". |
| anything else | Raises ValueError. |
For example, data.json = {"users": [...], "orders": [...]} becomes
two ProposedTables with sheet_or_json_path set to "users" and
"orders" respectively.
flyquery does not flatten nested JSON objects — that's DuckDB's job
via read_json_auto. The materialise call is:
COPY (SELECT * FROM read_json_auto(
'{src}', sample_size={type_infer_sample_rows}, format='auto'))
TO '{tgt}' (FORMAT PARQUET, COMPRESSION 'snappy')DuckDB infers struct types for nested objects and array types for nested
arrays. The resulting Parquet columns can be STRUCT(name VARCHAR, age INT)
rather than two separate columns. Downstream stages handle struct types
as a single column whose data_type is the DuckDB struct representation.
These four formats already carry their own schema, so flyquery skips all inference and reads the metadata directly.
| Format | Reader | Materialise strategy |
|---|---|---|
| Parquet | parquet_reader.py |
shutil.copyfile to the target key (zero conversion). |
| Avro | avro_reader.py |
fastavro -> pyarrow.Table.from_pylist -> pq.write_table with snappy. Full file is materialised in memory — see "row count requires full scan" comment. |
| ORC | orc_reader.py |
pyarrow.orc.ORCFile.read() -> pq.write_table. |
| Arrow / Feather | arrow_reader.py |
pyarrow.feather for .feather; for .arrow tries pyarrow.ipc.open_stream first then falls back to feather (some .arrow files are Feather v2). |
All four use pyarrow.Schema.field(i).type as the column's data_type
string — so a Parquet column declared int64 shows up as "int64" in
flyquery_schema_objects.data_type, while a CSV column inferred as
integer shows up as "BIGINT" (DuckDB's display name). This naming
asymmetry is a known wart; both retrieval and the relations heuristic
have type-group normalisers (relations.py:_NUMERIC_GROUP) so the
downstream code doesn't care.
DuckDB does the actual inference. flyquery configures it via:
sample_size = settings.type_infer_sample_rows(default 8192 —config.py:68)auto_detect = truealwaysdateformat = '%d/%m/%Y'for European locales, otherwise unset
DuckDB reads the first sample_size rows, picks the narrowest type
that fits all sampled values per column, then casts subsequent rows
into that type. The precedence (DuckDB's, not flyquery's) is roughly:
BOOLEAN < INTEGER < BIGINT < HUGEINT < DOUBLE
< DATE < TIMESTAMP < TIMESTAMP WITH TIME ZONE
< VARCHAR
If a column has "42", "42.5", "forty-two" in sample rows, DuckDB
widens to VARCHAR (the only type that fits all three). With
ignore_errors=false (the default for CSV — csv_reader.py:89), a
cast failure on a non-sampled row aborts the read. The Excel path uses
ignore_errors=true because flatten-CSV intermediate files routinely
contain coerced strings that should silently become NULL.
NULL, empty cells, the literal string "NULL" (case-insensitive when
null_padding=true), and Excel None/"" cells all become Parquet
NULL. The is_nullable flag on ColumnSchema is read directly from
DuckDB's DESCRIBE output (csv_reader.py:106).
DuckDB treats 2026-01-15T10:30:00Z (with Z or +00:00) as
TIMESTAMP WITH TIME ZONE and 2026-01-15T10:30:00 (no offset) as
naive TIMESTAMP. The profile.py:_TEMPORAL_TYPES set recognises both
("timestamp", "timestamp with time zone", "timestamptz") so the profile
stage computes min/max for either.
Given a 3-row CSV:
amount
42
42.5
forty-two
With type_infer_sample_rows=8192, DuckDB reads all three (≤ sample
size), tries INTEGER (fails on "42.5"), tries DOUBLE (fails on
"forty-two"), and widens to VARCHAR. The resulting Parquet column
is VARCHAR, all three rows preserved as strings. No row is dropped.
If the file were 100,000 rows where "forty-two" only appeared at
row 50,000, DuckDB would still see it within the sample (because the
sample is the first N rows, but DuckDB also has a fallback "sniff
multiple chunks" mode — see DuckDB's own docs for the details). If the
sample missed it, the row read would either error (ignore_errors=false)
or silently NULL the cell (ignore_errors=true).
Source: src/flyquery/core/services/ingestion/stages/parse.py:135-217
Prompt: src/flyquery/resources/prompts/column_name_proposer.yaml
Agent: src/flyquery/core/agents/column_name_proposer_agent.py
When DuckDB hands back columns named column00, column01, ..., or
when the first row was so weird that DuckDB picked actual data values
as headers (a number, an address string), flyquery rewrites them.
column_name_proposer_agent.py:109-135. Three triggers in order:
- All-synthetic: every name matches
^column\d+$— DuckDB had no header band at all (classic dashboard-XLSX with merged title cells). - All data-like: every name looks like a data value (all numeric, all long-text, all date-ish).
- Majority data-like: ≥ half the names look like data values, with a minimum of 1 — a section with 3 columns where 1 has a literal address as name is still rewritten.
Clean inputs (id, name, email) pass through with no LLM call — verified
by the test "Clean inputs pass through untouched".
If the trigger fires, _read_parquet_sample (parse.py:107-132) pulls
the first 5 rows from the just-materialised Parquet via DuckDB. The
agent sees:
section_label— the section name from XLSX section extraction (e.g. "Activos", or the table name for non-XLSX).current_names— the literalcolumn00/ data-value names from DuckDB.sample_values— up to 5 values per column.
The prompt (column_name_proposer.yaml) demands:
- exactly
len(current_names)proposed names, aligned 1:1 - lowercase ASCII, snake_case, ≤ 50 chars, starts with a letter, unique
- echo back already-good business names unchanged
- specific patterns for dates (
period_end_2024), currencies (currency), addresses (street_address,city) - fall back to
<section>_col_<n>when truly uncertain
Output is forced into a Pydantic ProposedColumnNames model (the agent
uses pydantic-ai), so malformed JSON can't crash the pipeline.
_rename_parquet_columns (parse.py:71-104) rewrites the Parquet in
place using DuckDB:
COPY (SELECT col0 AS new0, col1 AS new1, ... FROM read_parquet('{src}'))
TO '{tmp_path}' (FORMAT PARQUET, COMPRESSION 'snappy')The temp Parquet is then renamed over the original. Types and nullability are preserved; only column names change.
The agent runs against settings.describe_model (default
anthropic:claude-haiku-4-5). One call per section that triggers the
proposer; payload is small (5 rows × N columns). At haiku pricing
($0.80/M input + $4/M output) the average cost per section is on the
order of $0.001-$0.002 — well under the typical describe cost.
There is no batch budget on the proposer call specifically (no
column_name_proposer_budget setting); it's bounded by the
ingest_section_concurrency semaphore (default 8 concurrent — see
config.py:96).
If ANTHROPIC_API_KEY is missing (parse.py:166-172), or the agent
returns a malformed list, or any exception fires, the column names
become <section>_col_<n> (activos_col_0, activos_col_1, ...).
These are at least scoped to the section so two different sections
don't collide in the search index.
Source: src/flyquery/core/services/ingestion/stages/reconcile.py
Every materialised table becomes a new flyquery_schema_snapshots
row. The reconcile stage compares the new snapshot against the
previous READY snapshot for the same table_id.
reconcile.py:106-110:
added = [name for name in new_columns if name not in prev_columns]
removed = [name for name in prev_columns if name not in new_columns]
type_changed = [name for name in new_columns
if name in prev_columns
and new_columns[name] != prev_columns[name]]Note: type comparison is exact string equality on the DuckDB type
display name. INTEGER -> BIGINT registers as a type change even
though both are integer. The grounding agent ignores this nuance —
it normalises via relations._type_group.
Run only when both removed and added are non-empty.
- Group by type: split removed and added columns by their declared data_type.
- Unambiguous match — exactly one removed and one added column of
the same type:
- Auto-confirm as
RENAMED. No LLM call.
- Auto-confirm as
- Ambiguous match — N removed and M added of the same type (N ≥ 2
or M ≥ 2):
- Invoke the
RenameDetectionAgent(rename_detection.yaml). - If the agent's top proposal has
confidence ≥ 0.8(constant_DEFAULT_AUTO_CONFIRM_THRESHOLD = 0.8,reconcile.py:27), it becomes aRENAMEDrow. - Otherwise it becomes a
RENAMED_CANDIDATErow — human review required.
- Invoke the
The state is separate because acting on it is irreversible. A
confirmed RENAMED flips the schema_objects' annotation transplant —
human-set descriptions, synonyms, business owner, governance JSON
flow from the old name to the new name. A wrong rename would orphan
the annotations.
RENAMED_CANDIDATE is a proposal: the change row sits in
flyquery_schema_changes with after_json.candidates = [list of candidate new names]. The annotation transplant is not applied
until an operator confirms.
The dataset-level field is flyquery_datasets.drift_policy
(entities/dataset.py:32). The interface enum
(interfaces/datasets.py:17, 26, 38) declares only two values:
drift_policy: Literal["AUTO", "MANUAL"] = "AUTO"There is no STRICT mode in the code today. The original spec mentions one, and other docs reference it, but the current codebase only supports:
| Policy | Behavior |
|---|---|
AUTO |
Confirmed renames auto-apply; candidate renames stay as RENAMED_CANDIDATE. The upload always completes. |
MANUAL |
Same diff is recorded but no auto-confirmation flows through. (The actual gating logic for MANUAL is at the controller layer — see schema_changes_controller.py.) |
A future STRICT mode would reject the upload when drift is detected; operators relying on it should treat that as v1+ scope.
POST /api/v1/schema-changes/{change_id}:confirm
web/controllers/schema_changes_controller.py:37-76. It:
- Validates the change is in state
RENAMED_CANDIDATE. - Sets
change = 'RENAMED',approved_by = actor,approved_at = now(). - Updates
last_changed_aton the correspondingflyquery_schema_objectscolumn row.
There is no batch confirm endpoint today — operators confirm one change at a time.
_mark_removed_columns_inactive (reconcile.py:373-405) flips
is_active = false on the previous snapshot's column row but does
not delete the row. This preserves historical pinning: a query saved
against an old snapshot can still resolve the column name to its
historical type and description.
Source: src/flyquery/core/services/pii/, stages: sample.py + pii_tag.py
adapters/regex_scanner.py. Five patterns, applied in this order:
| Tag | Pattern |
|---|---|
EMAIL |
\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b (RFC 5322 simplified) |
SSN |
\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b (excludes US invalid prefixes) |
CREDIT_CARD |
\b(?:\d{4}[ \-]?){3}\d{4}\b — Luhn-friendly groupings, no checksum validation |
PHONE |
(?<!\d)(?:\+?\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)|\d{2,4})[\s.\-]?\d{3,4}[\s.\-]?\d{3,4}(?!\d) (≥7 digits) |
IP_ADDRESS |
Standard IPv4 dotted-quad with octet bounds |
Plus column-name keyword hints (_NAME_HINTS): email / mail,
ssn / social_security, phone / mobile, credit_card /
card_number, ip / ip_address. A hit on the column name alone
tags the column even before sample scanning.
adapters/presidio_scanner.py (66 lines). Wraps Microsoft Presidio when
the [presidio] extra is installed. Provides ~50+ entity types
including names, addresses, dates of birth, medical record numbers, and
language-specific identifiers.
Selected via FLYQUERY_PII_SCANNER=presidio. If the import fails (extra
not installed), the factory raises ImportError loudly rather than
silently falling back — this is intentional, pii/factory.py:36.
PII tagging is separate from semantic type classification. Semantic
types (describe.yaml) classify what a column represents (amount,
percentage, country_code, date), while PII tags flag privacy-sensitive
contents (EMAIL, SSN, PHONE). Some overlap exists (email semantic type
implies EMAIL PII tag) but the codepaths are independent.
See docs/semantic-types.md for the full taxonomy.
settings.pii_policy_samples (default "redact") — pii_tag.py:38:
| Policy | Action |
|---|---|
warn |
Tag pii_tag + log; samples stay visible. |
redact |
Tag pii_tag + sample_values_json = '[]'::jsonb. |
reject |
Tag pii_tag + is_active = false. Column hidden from grounding/retrieval; human must review. |
There is also an inline gate at sample time (sample.py:71-77):
every value sampled is passed through scan_single BEFORE persistence,
and PII-tainted values are silently dropped. If all sampled values
are tainted, no sample row is persisted at all. This is a defense-in-
depth measure — even if the column-level policy is warn, you can't
accidentally store a sample value that's PII.
reconcile.py:332-370 (_load_human_annotations) carries forward
pii_source = 'HUMAN' from the previous snapshot. If an operator
manually tagged a column on a previous snapshot, the new snapshot
inherits that tag — agent passes do not overwrite human-set values.
The same protection covers description_source = 'HUMAN' and
business_owner IS NOT NULL.
Source: src/flyquery/core/services/ingestion/stages/describe.py
Prompt: src/flyquery/resources/prompts/describe.yaml
_load_undescribed_columns (describe.py:157-214) picks active columns
where both description IS NULL AND description_source IS NULL.
A human-set description (description_source = 'HUMAN') is skipped
forever; the agent never overwrites it.
_build_prompt (describe.py:138-154) constructs:
{
"qualified_name": "sales_dataset.orders.total_amount",
"data_type": "DOUBLE",
"samples": ["1234.56", "987.00", "1500.00", "23.10", "45000.00"],
"table_context": ["id", "customer_id", "order_date", "currency", ...]
}samplesis capped at 5 values (already PII-filtered by stage 4).table_contextis the list of sibling columns under the same parent TABLE, capped at 20 names.
For each column:
| Field | Purpose |
|---|---|
description |
1-2 sentence business description (≠ "the column name in English"). |
synonyms |
3-8 alternative business names the analyst might use. |
semantic_type |
One of the 49 taxonomy labels (see describe.yaml). |
Storage:
description,synonyms_json-> direct columns.semantic_type-> stored insidegovernance_jsonas{"semantic_type": "<label>"}(no schema migration was required;describe.py:243-251).
- Batch size:
settings.describe_batch = 20columns per LLM call. - Per-run budget:
settings.describe_budget_cents_per_run = 200cents (i.e. $2.00). - Cost accounting uses haiku pricing: $0.80/M input + $4/M output
tokens (
describe.py:29-30).
When the budget is exhausted, remaining columns are flagged as
deferred. They'll be picked up by the DESCRIBE_PASS background
job — IngestWorker._run_describe_pass (workers.py:568+) — on its
next scan.
description_source ∈ {HUMAN, AGENT}.
HUMANalways wins. Agent passes filter outdescription_source = 'HUMAN'.AGENTis overwritten by subsequent agent passes only if the prompt changes (currently no prompt-version tracking — see §9.4).
Source: src/flyquery/core/services/ingestion/stages/embed.py,
src/flyquery/core/services/retrieval/embedder.py
_build_embed_text (embed.py:128-142) builds:
<qualified_name>: <data_type>
<description>
Synonyms: <comma-joined list>
Every flyquery_schema_objects row in the snapshot (both TABLE and
COLUMN kinds) gets one embedding. The text combines:
- The qualified name (
sales.orders.total_amount). - The data type, appended after a colon.
- The agent-generated description (if any).
- The synonyms list (if any).
config.py:114-116 — embedding_provider is a Literal over:
ollama, openai, cohere, voyage, azure, google, mistral, bedrock, null
Defaults: ollama + nomic-embed-text (768-d native). This lets
docker compose up work end-to-end without paid API keys.
| Provider | Notes |
|---|---|
ollama |
Local embeddings; requires Ollama running on embedding_base_url. |
openai |
text-embedding-3-{small, large} |
cohere |
embed-english-v3.0 (1024-d) |
voyage |
voyage-3, voyage-large-2 |
azure |
Azure OpenAI deployments |
google |
Vertex AI text-embedding |
mistral |
mistral-embed |
bedrock |
AWS Titan / Cohere on Bedrock |
null |
No vectors written; retrieval uses BM25 over content_tsv only. |
If a provider's API key / endpoint is unreachable, the factory
(embedder.py:206-216) downgrades to NullEmbedder rather than
failing — ingestion completes and BM25 retrieval still works.
The flyquery_schema_objects.embedding column is fixed at
vector(settings.embedding_dimensions) — default 1536.
Native dimensions per provider:
nomic-embed-text: 768- OpenAI
text-embedding-3-small: 1536 (native — no padding) - Cohere v3: 1024
- Voyage: 1536
FireflyEmbedder._pad (embedder.py:146-158) zero-pads to 1536 when
the native dim is smaller, and truncates when larger.
Cosine similarity is preserved under zero-padding because zero coordinates contribute zero to both the dot product and the L2 norms. Rankings stay stable. Truncation is genuine information loss but is rare — only providers whose native dim exceeds 1536 are affected.
run_embed is called per-snapshot at ingestion time. The system
does not re-embed columns when:
- The describe prompt changes.
- The embedding provider changes.
- The embedding model is upgraded.
There is no EMBED_PASS job in workers.py — this is documented v1+
scope. The workaround today is to trigger a REPARSE job (which
re-runs stages 1-3 + 9-10) for the affected table.
Whether or not embeddings succeed, every object gets a
content_tsv PostgreSQL tsvector built from the embed text
(embed.py:158-195):
content_tsv = to_tsvector('english', :tsv_input)Input is capped at 4096 chars (PostgreSQL tsvector limit guard). Retrieval can hit this even when the vector column is NULL.
Operator uploads a 4.2 MB file Q1 dashboard.xlsx containing 6 sheets:
| Sheet | Layout |
|---|---|
Cover |
Logo image + 2 merged-cell text rows. No tabular data. |
KPIs |
Single section with 4 columns, 3 rows. |
Sales |
Two sections: "By region" (5 cols × 8 rows), "By product" (4 cols × 15 rows). |
Costs |
One section with 6 columns, 25 rows. Merged-cell title in row 0. |
Headcount |
One section, but the first row is 5302354.32 (looks like a number — Excel exported with no header). 7 columns, 12 rows. |
Notes |
Free-text paragraphs — no tabular structure. |
| Stage | What happens | Wall-clock (est.) |
|---|---|---|
| 1. receive | SHA-256 hash computed. Cap check passes (4.2 MB < max_file_mb=2048). detect_format("Q1 dashboard.xlsx", head_bytes) -> ("xlsx", "none"). flyquery_files row inserted with status='RECEIVED'. |
~50 ms |
| 2. parse | python_calamine opens the workbook. _extract_sections runs once per sheet. Cover and Notes yield 0 sections each — skipped. Sales yields 2 sections. Net: 5 ProposedTables (KPIs, Sales__By_region, Sales__By_product, Costs, Headcount). Each materialised to a temp CSV, then DuckDB writes Parquet. Headcount triggers needs_proposal=True (all column names are numeric data values) — the column-name proposer agent rewrites the 7 columns to e.g. [employee_id, name, department, salary, hire_date, manager_id, status]. Costs has its merged title row stripped by the max_title_rows=3 heuristic OR by the section extractor recognising row 0 as a 1-cell label. |
600-900 ms (LLM call for Headcount adds ~700 ms) |
| 3. reconcile | First upload — prev_snapshot = None for all 5 tables. No diff written. For each table, one flyquery_schema_snapshots row + one TABLE schema_object + N COLUMN schema_objects. Status PARTIAL. |
~150 ms × 5 = 750 ms |
| 4. sample | For each column without pii_tag, read up to sample_n=8 non-null values from the Parquet. Each value passed through RegexPiiScanner.scan_single. Clean values persisted to sample_values_json. |
~50 ms × 5 = 250 ms |
| 5. profile | Per column: count, null_count, approx_count_distinct. For numeric/temporal: min, max. For low-cardinality (distinct ≤ 100): top 5. Skipped if any single table exceeds profile_row_threshold=10M rows (none do here). |
~30 ms × 5 = 150 ms |
| 7. describe | Across 5 tables, ~30 columns total. Batched at 20/call, so 2 LLM calls. Each column gets a description, 3-8 synonyms, and a semantic_type. Budget consumed: ~$0.05. | 2-4 seconds (haiku is fast) |
| 9. embed | All ~35 schema_objects (5 TABLE + 30 COLUMN) embedded in a single batch call. With Ollama + nomic-embed-text, 768-d vectors zero-padded to 1536. content_tsv written for every row. |
500-800 ms |
| 10. publish | Atomic flip: each snapshot status='READY' + flyquery_tables.current_snapshot_id updated. flyquery.schema.updated EDA event published. |
~100 ms × 5 = 500 ms |
Total wall-clock estimate: ~6-9 seconds for the synchronous path.
Async-deferred:
- Stage 6 (relations) runs in the worker. Will discover that
Sales__By_region.regionandSales__By_product.regionshare a name + type + look like a heuristic join. - Stage 8 (pii_tag) runs in the worker. May tag
Headcount.salary(no PII match) andHeadcount.employee_id(no PII match unless the values look like SSNs).
flyquery_files: 1 row (status=RECEIVED)
flyquery_tables: 5 rows (KPIs, Sales__By_region,
Sales__By_product, Costs,
Headcount)
flyquery_schema_snapshots: 5 rows (status=READY)
flyquery_schema_objects: 5 TABLE + ~30 COLUMN = ~35 rows
flyquery_schema_changes: 0 rows (first upload, no diff)
flyquery_relations: ≥1 row from heuristic 6a
(after async worker runs)
If the operator re-uploads the same file a week later with region
renamed to region_name in Sales__By_region:
- Stage 3 detects
regioninremoved,region_nameinadded, same data_type (VARCHAR). - Unambiguous match → auto-confirmed
RENAMED. - Annotation transplant: any HUMAN description on
regionflows toregion_name. flyquery_schema_changesgets oneRENAMEDrow.
If they also renamed Costs.amount to Costs.cost_amount AND added
Costs.amount_eur of the same VARCHAR type:
- Two removed columns and two added columns with overlapping types.
- Ambiguous →
RenameDetectionAgentinvoked. - If the agent returns
(amount, cost_amount, confidence=0.92)→ auto-confirmedRENAMED; the leftoveramount_euris treated asADDED. - If the agent returns
(amount, cost_amount, confidence=0.65)→ becomesRENAMED_CANDIDATE. Operator must POST to/api/v1/schema-changes/{change_id}:confirm.
DuckDB's sniffer trusts the first 4 KB. If your CSV's first 4 KB contain non-representative content (e.g. a comment header + a single sparse row), the sniffer guesses wrong.
Workaround: there is no public delimiter override on the upload API
today. The only knob is type_infer_sample_rows (config.py:68,
default 8192) which controls how many rows DuckDB sees, not the
delimiter choice. If you control the export, prefer TSV (which always
uses tab) or a Parquet file (no inference at all).
The section extractor should handle this — a single-cell-string row at the top of the sheet becomes a section label, not a header. If you see the title text appearing in column values:
- Check that the title cell is actually a single string and not a merged range that python-calamine returns as multiple non-empty cells.
- The legacy backward-compat path (sheet with one section covering the
whole sheet) uses
max_title_rows(default 3,config.py:67). If your title banner is more than 3 rows tall, bump this knob. - Excel "Group Boxes" and "Comments" sometimes show as cells. They're not — they're metadata. python-calamine ignores them.
Two likely causes:
- The column has mixed formats (
2026-01-15and15/01/2026in the same column). DuckDB widens to VARCHAR. Fix at the source. - Your locale is
en-US(the default) but the dates areDD/MM/YYYY. Thedateformathint isn't applied (csv_reader.py:141-143— only European locales get the hint). Set the workspace'sdefault_localetoen-GB/fr/es/de/it/pt/nland re-upload. The locale flows throughIngestServiceand ontoreader.materialise(workspace_locale=...).
It happens when two unrelated columns share a data_type and the LLM confidence overshoots 0.8.
Recovery path:
- Set the dataset's
drift_policy = 'MANUAL'. Future uploads will record diffs but not auto-confirm renames. - To roll back the false positive: re-upload the file with the rename
intent inverted, or manually update the
flyquery_schema_changesrow tochange = 'RENAMED'with an empty annotation transplant (requires direct DB access today — there's no REST endpoint for un-renames). - The
_DEFAULT_AUTO_CONFIRM_THRESHOLD = 0.8constant is not currently exposed as a setting (reconcile.py:27). Tightening it to 0.9 requires a code change.
docs/file-formats.md— per-format quirks and the upload payload reference.docs/semantic-types.md— the full 49-label taxonomy used byDescribeAgent.docs/prompts.md— every prompt template flyquery ships, with versioning rules.docs/ingestion.md— top-level ingestion guide including the EDA flow, idempotency contract, and partial-failure recovery.docs/pii.md— PII scanner internals + the configuration matrix.docs/embeddings.md— embedding provider details and the BM25-fallback retrieval path.
| Setting | Default | Effect on schema detection |
|---|---|---|
max_file_mb |
2048 | Per-file upload cap (HTTP 413 above this). |
max_workspace_gb |
200 | Workspace-wide storage cap. |
type_infer_sample_rows |
8192 | Rows DuckDB inspects for CSV / JSON / XLSX-as-CSV type inference. |
max_title_rows |
3 | Title-row banner skip in legacy XLSX path. |
default_locale |
en-US |
Determines dateformat hint for CSV. European locales get %d/%m/%Y. |
sample_n |
8 | Sample values pulled per column in stage 4. |
profile_row_threshold |
10_000_000 | Tables larger than this skip stage 5 entirely. |
describe_batch |
20 | Columns per describe-agent LLM call. |
describe_budget_cents_per_run |
200 | Per-snapshot describe budget (cents). Excess deferred to DESCRIBE_PASS. |
relation_proposer_enabled |
true | Toggles stage 6b (LLM relation proposer). |
relation_heuristic_min_confidence |
0.5 | Minimum heuristic-stage confidence for relation insert. |
relation_proposer_max_per_pair |
3 | Cap on agent proposals per table pair. |
pii_scanner |
regex |
regex / presidio / disabled. |
pii_policy_samples |
redact |
warn / redact / reject. |
embedding_provider |
ollama |
Selects embedding backend. null -> BM25-only retrieval. |
embedding_model |
nomic-embed-text |
Provider-specific model name. |
embedding_dimensions |
1536 | Fixed column width of vector(N). Smaller native vectors are zero-padded. |
embedding_native_dim |
768 | Native dim of the selected model. |
ingest_section_concurrency |
8 | Parallel XLSX sections (each section can fire 1 describe + 1 column-naming LLM call). |
ingest_worker_concurrency |
4 | Async ingest worker fan-out for deferred jobs. |
| Concern | Path |
|---|---|
| Format detect | src/flyquery/core/services/ingestion/format_detect.py |
| Compression | src/flyquery/core/services/ingestion/compression.py |
| Reader dispatch | src/flyquery/core/services/ingestion/reader_factory.py |
| Per-format readers | src/flyquery/core/services/ingestion/readers/*.py |
| Pipeline orchestration | src/flyquery/core/services/ingestion/ingest_service.py |
| Stages | src/flyquery/core/services/ingestion/stages/*.py |
| Async worker (DESCRIBE_PASS / RELATION_PASS / REPARSE) | src/flyquery/core/services/ingestion/workers.py |
| Settings | src/flyquery/config.py |
| Prompts | src/flyquery/resources/prompts/*.yaml |
| Embedder | src/flyquery/core/services/retrieval/embedder.py |
| PII | src/flyquery/core/services/pii/ |
| Schema-change confirm endpoint | src/flyquery/web/controllers/schema_changes_controller.py |
| Derived tables (SELECT-as-table) | src/flyquery/core/services/derived/derived_table_service.py |