Skip to content

feat(ai): give the schema prompt to the tables the question is about - #3750

Merged
datlechin merged 11 commits into
TableProApp:mainfrom
J2TeamNNL:feat/ai-schema-relevance
Oct 22, 2026
Merged

datlechin merged 11 commits into
TableProApp:mainfrom
J2TeamNNL:feat/ai-schema-relevance

Conversation

@J2TeamNNL

@J2TeamNNL J2TeamNNL commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • AI chat and inline suggestions send columns for at most maxSchemaTables tables (20 by default), and took the first ones listed. Each turn now gives that budget to the tables it is about.
  • First come the tables the editor's query reads, through the new SQLContextAnalyzer.tableReferences(in:grammar:). In chat this covers every statement in the 2,000 characters the prompt carries, while Include current query is on and the editor is SQL. Inline suggestions use the statement at the cursor.
  • A referenced name puts first every table it could mean, matched without case and the default schema's table first, since engines fold unquoted names differently.
  • The question's matches follow: BM25 over identifier words with trigram matching for typos (SchemaRelevanceRanker in TableProAutocomplete). The ranker reads table names and comments. It reads columns only once every table has them, so a table loaded on an earlier turn never outranks one whose columns were never read.
  • With at most 300 tables and a driver whose fetchAllColumns is one query (new DatabaseDriver.providesBulkColumnFetch), the first ranked turn reads every column in that one query.
  • A custom slash command ranks by its body, not by the schema it renders. Before the connection's AI policy allows access, it reads no more than it did before.
  • Tables past the budget stay a count line. The chat model reaches them with list_tables and describe_table.
  • The chat's column cache is dropped on a database or schema switch. Before, a same-named table in the new database kept the old database's columns.
  • ALTER TABLE schema.table now resolves to table in schema, for completion too, where it resolved to a table named schema.

What reaches the model is unchanged: the same settings and budget decide it. Only which tables fill the budget changes.

Docs: TableProApp/docs#122

Test plan

  • SchemaRelevanceRankerTests and SQLContextAnalyzerTableReferenceTests (TableProCore, swift test): ranking, a statement ending in a comment or open string, a CTE that shares a table's name, a qualified ALTER target.
  • AISchemaContextRelevanceTests: question order, table comments, columns only when every table has them, editor tables first, SQL keywords match nothing, every statement counts, default schema first, case collisions keep both, the count line.
  • SchemaContextForAITests: the tables the cursor's statement reads take the inline budget, including a table named after the first 2,000 characters.
  • AIChatViewModelSlashTests: a custom command ranks by its body; a tool result turn does not replace the question.

@J2TeamNNL
J2TeamNNL marked this pull request as ready for review October 21, 2026 08:46

@J2TeamNNL J2TeamNNL left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review notes on the schema relevance ranking. Each comment is a place where the behavior can go wrong.

}
}

private func prioritizeSchema(for question: String) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ranking only sees columns for tables that are already loaded, and those are the first tables in default order. On the first question no columns are loaded, so a question like "which rows have an email" cannot find a table at position 300 by its column. On later turns the tables loaded earlier outrank better matches that are not loaded yet.

}
}

private func prioritizeSchema(for question: String) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A follow-up that matches no table ("and by month?") sets relevantTableNames to an empty list. The next prompt then falls back to alphabetical order and the tables the chat is about can drop out of the first maxTables. Keep the previous ranking when the new one is empty.

}

private func prioritizeSchema(for question: String) {
let text = [question, currentQuery ?? ""].joined(separator: "\n")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

currentQuery is joined into the ranking text. SQL words like order, group, user and table then match table names at full weight. SELECT * FROM x ORDER BY y boosts orders and order_items.

await inFlight.value
return
// The load in flight fetched for the previous order; a new order may need other tables.
guard question != nil else { return }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Race in ensureSchemaLoaded. Two callers that wait on the same in-flight load both resume afterwards. The first sets inFlightSchemaLoad = nil and can clear the task the second caller just stored, so a third caller starts a duplicate load. Clear it only when it is still your own task.

counts[word, default: 0] += 1
}
}
for term in counts.keys {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fuzzy matching builds trigram sets for the query word and for every vocabulary term on each comparison. With thousands of tables this allocates a lot, and it runs on the main actor. Compute term trigrams once per index.

var score = 0.0
for match in matches {
guard let frequency = terms[match.term] else { continue }
score += match.weight * index.bm25(frequency: frequency, idfTerm: match.term, length: index.lengths[offset])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The full-name boost uses substring containment on the joined query words. A table data gets +5 from "metadata", and user from "username". The +5 outweighs the BM25 score. Match whole words in order instead.

}
return word
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The stemmer maps "statuses" to "statuse" but leaves "status" alone, and "caches" to "cach". It also skips non-ASCII words, so a question in CJK or Cyrillic shares no words with English identifiers and nothing ranks.

lines.append(
"\n… and \(tables.count - maxTables) more tables (not shown)"
)
let hiddenTables = tables.dropFirst(maxTables)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Up to 200 hidden table names are now added to every prompt, where before it was a count line. There is no token budget for them, so a database with 1000+ tables pays this on every message.

guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
relevantTableNames = AISchemaContext.relevantTableNames(
in: tables,
columnsByTable: columnsByTable,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

relevantTableNames is shared mutable state set at the start of a turn and read later by orderedTables(). A slash command or an attachment prime that runs in between can render with another turn's ordering.

guard let self else { return }
if settings.includeSchema {
await self.ensureSchemaLoaded()
let question = await MainActor.run {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The question is read from messages inside the detached task after an actor hop, not when the turn is created. If the history changes in between (regenerate, edit), the ranking uses the wrong question. Capture it before the task starts.

… clear the schema load only when it is ours

A follow-up that matches no table no longer resets the ranking. The full-name boost needs the name's words in order in the question. Term trigrams are built once per index. The in-flight schema load is cleared only by the task that set it, and the question is read when the turn starts.
@J2TeamNNL

Copy link
Copy Markdown
Contributor Author

Follow-up to the review above. Fixes are in J2TeamNNL@8cf9ddf.

  1. Columns only for loaded tables: skipped. Ranking by columns of tables that are not loaded needs a column fetch for every table, which this PR avoids on purpose.
  2. Empty rank wipes the ranking: fixed.
  3. currentQuery in the ranking text: skipped. Table names in the editor query are the strongest signal, and stripping SQL words adds a keyword list for a small gain.
  4. Race in ensureSchemaLoaded: fixed.
  5. Fuzzy matching cost: fixed for the allocations (term trigrams built once). Moving it off the main actor is skipped, the work is now one pass over the vocabulary.
  6. Name boost on substrings: fixed, whole words in order, with a test.
  7. Stemmer: skipped. The trigram match already covers statuses and status, and a non-Latin question ranking nothing is the same as before this PR.
  8. 200 hidden table names: skipped. The cap is deliberate and bounded.
  9. Shared relevantTableNames: skipped. It runs on the main actor, is set only at turn start, and the load that follows uses the same order.
  10. Question read in the detached task: fixed.

Checks: swiftlint --strict on ViewModels and Core/AI is clean. The ranker tests pass in a scratch package (8 of 8). The app target was not built here, so CI has to confirm it.

@datlechin
datlechin merged commit b9b1170 into TableProApp:main Oct 22, 2026
57 of 59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants