How to Keep Power BI Copilot in Its Fast Lane

This is a companion piece to How Power BI Copilot Works Under the Hood. In the main post I traced the full pipeline from question to answer, but one of the more practical findings was about the two paths Copilot can take to resolve a data question. One is fast and reliable. The other is slow, expensive, and prone to hallucination. Which path you land on depends on how your model is set up and how you phrase the question.

Two tiers, one question

When you ask Copilot a data question through answerDataQuestion, it first tries a semantic parser. This is a structured query engine that resolves your question against the model’s relationships, column names, and hierarchy levels. In the UI, you’ll see “Checking the underlying data…” while it works. The query I described in the main post (warehouse clothing sales in the US by month, joining four tables with three filters and three aggregations) resolved in about 2 seconds through this path.

If the parser can’t handle the question, Copilot falls back to generating DAX with an LLM. The UI shifts to “Generating a DAX query…” and the wait gets longer. One of my test queries took 107 seconds to generate DAX, then 173 milliseconds to execute it. The bottleneck isn’t running the query; it’s writing it. And because it’s freeform code generation, the results are less predictable.

So the practical question becomes: what keeps you in the first tier?

What the parser handles

The parser is good at questions that map to a single pass over the data with grouping, filtering, and basic aggregation. Think of it as roughly equivalent to what you could express in a single SELECT with GROUP BY. From the 24 queries I tested:

Simple aggregations work. “Total sales amount,” “average unit price,” “count of orders.” Filters on specific values work. “Sales in the United States” resolves because the parser matches “United States” to an actual value in the country column. Top N queries have native support; “top 10 customers by sales amount” stays in the fast path. Sorting works in both directions.

Multi-table joins work as long as the tables are connected through your model’s defined relationships. “Sales by customer country” resolves because sale→customer→country follows the relationship chain. The parser also has built-in relative date functions, so “last year” and “this year” work regardless of what dates are actually in your dataset. These are more robust than hardcoded years, which sometimes appeared in Copilot’s own suggested follow-up queries.

What surprised me was how much the parser could combine. That warehouse clothing query joined four tables (sale, reseller, product, sales territory, date), applied three filters (Warehouse, Clothing, United States), computed three aggregations (sum of quantity, average unit price, average discount), sorted by month, and resolved in 2 seconds. All in the fast path.

What falls through to DAX

The boundary is roughly: if the answer requires comparing results across different slices or building on intermediate calculations, the parser can’t express it. Year-over-year growth needs two separate period totals and a comparison between them. Percentage changes need the same. “Which products grew the most AND which declined?” asks for both sides of a ranking simultaneously, which requires multiple passes.

In my testing, every query that involved conditional logic across different analytical dimensions (“products where sales increased but returns also increased”) or multi-step analysis (“average sales for customers who bought more than 10 items”) fell through to DAX generation. The parser is single-pass by design; anything that needs a subquery or self-join is out of scope.

Setting your model up for the fast path

The single highest-leverage thing is descriptive names. The parser resolves natural language against your table, column, and measure names. sales_amount gives it something to work with; sa_amt_v2 does not.

Synonyms on hierarchy levels matter more than I expected. In the main post I documented a case where the parser refused a query about “category” because Product[Category] was hidden, even though the hierarchy level Product → Product → Category was visible. The parser resolves through hierarchy paths, not just columns. Adding a single Terms: ["category"] synonym to the hierarchy level turned a DomainModelLimitation refusal into a working query. One word.

Column visibility is important and asymmetric. The parser respects IsHidden; if a column is hidden, the parser won’t use it and may refuse the query entirely. The DAX fallback tier doesn’t always respect this boundary the same way. I found cases where generated DAX used hidden columns through TREATAS. This is another reason to prefer staying in the parser tier: it actually honors the access controls you set.

Field descriptions have a side effect worth knowing about. Setting descriptions on columns in “Prep data for AI” can trigger auto-generated synonyms. In one case, a field description caused a synonym to appear that made a previously-failing query succeed. The description itself wasn’t what fixed it; the synonym it generated was.

If you know users will ask questions that would normally fall to DAX (year-over-year growth, percentage changes), write measures for them. The parser can use pre-built measures in a single pass, turning a two-tier question into a one-tier question.

Phrasing

Refer to columns and measures by something close to their actual names. “Total sales amount” resolves better than “how much money did we make” if your measure is called Sales Amount. Be specific about filter values: “sales in the United States for clothing” gives the parser concrete strings to match against column values, where “US clothing revenue” requires it to infer that “US” means “United States” and “revenue” means “sales.”

Split compound questions. “What are the top customers and how have they changed over time?” is two questions. The first part stays in the parser; the second (change over time) forces a DAX fallback. Ask them separately and both answers come back faster.

One thing I didn’t expect: conversation context can poison the parser. If a query gets refused in one turn, the refusal persists in the conversation’s context events and can contaminate subsequent queries. I tested the same question in a session where a prior refusal existed and in a fresh session. It failed in the contaminated session; it succeeded in the fresh one. If Copilot starts refusing things it should be able to handle, start a new chat.

How to tell which tier you’re in

Watch the status text in the Copilot pane. “Checking the underlying data…” means the parser is working. “Generating a DAX query…” means it fell through. In the Service (browser) environment you’ll also see a “View DAX query” button when the DAX tier was used. Desktop doesn’t surface this, but the fallback still happens behind the scenes.

If you’re consistently seeing “Generating a DAX query” on questions you think should be simple, that’s a signal to check your model setup: column names, visibility, and synonyms. The parser’s capabilities are wider than you’d expect. Usually when it falls through, the model is the bottleneck, not the question.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *