SharePointVault ~/gotchas/search ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ graph-search-raw-question-returns-nothing.md
sharepoint-vault›gotchas›search›graph-search-raw-question-returns-nothing.mdhistoryedit on GitHub
● GOTCHAsearch· 6 min read

Graph Search returns 0 hits — you passed the user's question as the queryString

applies to: Microsoft Graph Search API (v1.0), Exchange Online, Teamsreviewed: 2026-09-24#search#graph#kql#ai#rag

Graph Search treats queryString as full text, so a user's raw question matches nothing and fails silently — translate the question into keywords first, and answer "what's new" with a date-sorted listing rather than a keyword search.

Graph Search bere queryString jako plný text, takže syrová otázka uživatele nenajde nic a selže potichu – nejdřív otázku přelož na klíčová slova a „co je nového“ řeš výpisem řazeným podle data, ne hledáním klíčových slov.

Symptom

You wire POST /search/query into an assistant so it can answer from the user's own mail, calendar or Teams messages. Permissions are consented, the call returns HTTP 200, there is no error anywhere — and every result set is empty:

{ "value": [ { "hitsContainers": [ { "hits": [], "total": 0 } ] } ] }

The assistant then tells the user "I don't have access to your Teams history" — which is false, and the most expensive part of this bug: it fails silently. Zero hits is indistinguishable from "the user genuinely has nothing like that."

Cause

The question went into queryString verbatim:

{ "entityTypes": ["chatMessage"], "query": { "queryString": "what did I last discuss in Teams?" } }

Search treats that as full text. It looks for the words "what did I last discuss in Teams" inside messages. Nobody writes that, so nothing matches.

Two distinct problems hide in one sentence like that:

  1. A question is not a query. It needs translating into keywords first.
  2. "Last" / "what's new" is not a search at all — there are no keywords to extract. It's a listing request, and keyword search is the wrong tool for it.

Fix

1. Translate the question into keywords

Put a translation step between the question and queryString — a cheap model call or plain rules — and let "no keywords" be a legitimate outcome: a question that asks only about recency ("what's new?") has nothing to search for and belongs to the listing approach below. Never fall back to the raw question when the translation comes back empty; that is the bug you are fixing.

A person's name works as a plain keyword — Teams search matches the sender name too, so you rarely need from:.

2. For recency, lean on the default sort — don't sort yourself

⚠️ Correction (2026-07-17, verified live): queryString: "*" does NOT work for chatMessage alone. The response comes back 200 OK with "searchTerms": [] and "total": 0 — Graph discarded the wildcard, had nothing to match, and returned nothing. The * example in the docs is for entityTypes: ["chatMessage", "message"] (the interleaving combination), and it does not generalize to a single entity type. Treat the rest of this section as the goal (lean on the default date sort), not as a working recipe for * — and check searchTerms in the response: if it's empty, your query never became a query, no matter what the docs example shows.

* is documented (see the interleaving example that combines chatMessage and message) and the default sort is what makes recency work at all — a detail that's easy to miss:

message and event are sorted by date, all SharePoint/OneDrive/person types by relevance. — Microsoft Search API overview

and for Teams messages:

The search results are ordered by descending dateTime. — Search Teams messages

So queryString: "*" already returns the newest items. Meanwhile a custom sort is rejected:

The search API doesn't support custom sort for acronym, bookmark, message, chatMessage, event, person, qna, externalItem.

Passing sortProperties for those types returns HTTP 400. Relevance-boosting enableTopResults is fine for message/chatMessage keyword searches, but don't turn it on for the * listing — it undermines the only thing making that query work.

3. Date filters: use only the documented operator per entity type

There is no uniform KQL across entity types. Use what the docs actually show:

Entity Documented date filter Source
message (mail) received>=2026-07-10 Searchable email properties (KeyQL)
chatMessage (Teams) sent>2026-07-09 — no >= Search Teams messages, scope terms table
event (calendar) none documented no scope-terms table exists

Because the Teams operator is > without the equals sign, shift the date back one day so the boundary day stays inside the window:

// documented: sent>2022-07-14 · from:bob · to:bob · hasAttachment:true · IsMentioned:true · mentions:<id>
const dayBefore = shiftDays(sinceIso, -1);
queryString = `${keywords || '*'} sent>${dayBefore}`;

For event, send keywords only. Guessing a filter is not free: bad KQL is a 400, which takes the whole source down, whereas no filter merely widens the results. Search wide rather than not at all.

Validate the date where you build the query, not at the caller — if an LLM produced it, the "it's always YYYY-MM-DD" contract is held up by a prompt, which is not a guarantee:

// "2026-13-45" must not reach KQL: `new Date(2026, 12, 45)` silently rolls over to 2027-02-14
function parseIsoDay(iso: string): Date | null {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || '').trim());
  if (!m) return null;
  const y = +m[1], mo = +m[2], da = +m[3];
  const d = new Date(y, mo - 1, da);
  return (d.getFullYear() === y && d.getMonth() === mo - 1 && d.getDate() === da) ? d : null;
}

4. Calendar deserves a different endpoint

Since event has no documented date filter, time-bounded calendar questions ("meetings next week") are better served by /me/calendarView?startDateTime=…&endDateTime=… than by Search. Different data, though: calendarView covers the future, Search mostly the past.

5. Four traps waiting in /me/chats (all verified live)

Once you go native for Teams recency, GET /me/chats?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc works (that combination is fine, even though the docs only show $expand and $orderby separately) — but four things will bite you:

  1. Name the chat after the OTHER person, not the sender. lastMessagePreview.from is whoever wrote the last message — for your own messages, that's you. Chats then read "Chat with <your own name>". This is not cosmetic: an assistant asked "what did I discuss with Megan?" answers "I have no information" while citing that very message as its source — Megan's name never entered the context. Fix: $expand=lastMessagePreview,members (both are documented for list-chats) plus a cached GET /me?$select=id, then build the title from members minus yourself. Keep from for a "who wrote it" line.
  2. Prefer: include-unknown-enum-members — without this header, evolvable enums come back as the sentinel unknownFutureValue instead of the real value (systemEventMessage). Any filter you write on messageType then passes by luck, not intent.
  3. Self-chat ("message yourself") is NOT returned by /me/chats. If your test plan is "I'll message myself and ask about it", the test cannot pass no matter what your code does. Test against a real chat with another person.
  4. lastMessagePreview is only the LAST message. When that happens to be a system notice — e.g. a call-recording event, which arrives with from: null and a body of <systemEventMessage/> — the whole chat disappears from your results even though it contains real conversation. Covering that needs /chats/{id}/messages per chat.

Why this bites specifically in AI/RAG code

Retrieval pipelines built on SharePoint Search usually already have the "question → keywords" step. When you later bolt on a second retrieval path for Graph sources, it's easy to reuse the interface and skip the steps. Both paths then look correct in review, and only one of them works — silently.

If you have two search branches side by side and only one translates the question, the other is broken and nothing will tell you. Check what fills queryString.

Don't merge the two generators into one prompt either: SharePoint KQL properties (Write>=, filetype:, author:) don't exist in the Exchange/Teams index, and corpus/people modes make no sense there. One generator per index.

References

Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-09-24last updated: 2026-09-24historyedit on GitHubview source