Search Corpus Developer Guide - edmozley/freeitsm GitHub Wiki
Search corpus β Developer Guide
How searching inside tickets works underneath, and the handful of things about it that will bite you if nobody tells you.
The user-facing page is Searching inside tickets. The design reasoning β including the options that were rejected β is on Full-text search.
1. π The files involved
Colour key: ποΈ schema Β· βοΈ shared service Β· π API Β· π₯οΈ page Β· π¨ CSS Β· π§° script Β· π©Ί diagnostics Β· π§ͺ tests Β· π i18n
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
search_documents β the corpus table, its two full-text indexes and the cascade foreign key |
| ποΈ | includes/db_verify_schema.php |
The same table as columns + PK, so an existing install gains it on Verification |
| ποΈ | includes/db_verify_indexes.php |
Generated. Carries the index type (key / unique / fulltext) |
| ποΈ | includes/db_verify_index_parse.php |
The shared parser, and dbVerifyIndexTypeOf() |
| ποΈ | scripts/gen_db_verify_indexes.php |
Regenerates the index list from database/freeitsm.sql |
| βοΈ | includes/search/search.php |
THE search function. Query parsing, scopeβSQL, the two-query search, snippets |
| βοΈ | includes/search/corpus.php |
The only writer. Upsert/delete, HTMLβplaintext, the scope constants |
| βοΈ | includes/search/indexer.php |
searchIndexTicket() / searchIndexArticle() β the one definition of each source's corpus rows, plus the dispatch subscriber that keeps tickets current |
| βοΈ | includes/search/extract.php |
Attachment text, tier 1. Its own guide: Attachment text extraction |
| ποΈ | attachment_text (table) |
The durable extracted text. search_documents holds a derived copy |
| βοΈ | includes/services/knowledge.php |
reindexForSearch() β articles are indexed from here, by direct call. See Β§9a |
| βοΈ | includes/search/backfill.php |
Walks tickets then articles, calling the indexer. Batching and commits only |
| π§° | scripts/search_backfill.php |
CLI wrapper β --limit, --prune, --stats |
| βοΈ | includes/ticket_events.php |
ticketDispatchCreated() β the one place ticket.created is announced from |
| βοΈ | workflow/includes/engine.php |
Where the subscriber is hooked in, next to the notification bell's |
| π | api/tickets/search_content.php |
The inbox's content search. Builds a scope; decides nothing itself. Tickets only β it skips corpus rows with no ticket_id |
| π | api/system/global_search.php |
βK. Seven name-matching sources, then content hits as their own trailing group |
| π¨ | assets/js/command-palette.js |
ticket_content / article_content groups, rendered last |
| π₯οΈ | system/search/index.php |
System β Search β index status and the Rebuild button |
| π | api/system/search_status.php |
Read-only counts, coverage and the server's minimum word length |
| π | api/system/search_rebuild.php |
One slice of a rebuild per call. See Β§10 |
| βοΈ | system/includes/areas.php |
The search/ card on the System landing page |
| π₯οΈ | tickets/index.php |
One new field in the search modal |
| π¨ | assets/js/inbox.js |
performContentSearch() and renderContentSearchResults() |
| π¨ | assets/css/inbox.css |
.search-result-snippet, .search-field-hint, .search-results-note |
| π©Ί | api/system/debug-tools/D007_search_corpus.php |
Health check β table, indexes, FK, server settings, a live search |
| π§ͺ | tests/search/run.php |
41 assertions on parsing, scope and results |
| π§ͺ | tests/db-verify-indexes/run.php |
27 assertions that the index list understands FULLTEXT |
| π | lang/{en,pt-BR,nb,nn}/tickets.php |
search_modal.content*, part_*, found_in, too_short, not_indexed |
2. One corpus, not an index per table
search_documents holds one row per searchable unit β a ticket subject, a message, a note, and later an attachment's extracted text. They are all the same shape.
The temptation is a FULLTEXT index on emails, another on ticket_notes, and a UNION. Do not:
- Relevance scores from different full-text indexes are not comparable. Each is computed against its own index's corpus statistics, so there is no meaningful way to sort a note hit against an attachment hit.
- Pagination then has nothing coherent to page by.
- Every new searchable thing is another branch in the query, and another place to re-express the permission rules.
Adding a source is now an INSERT with a new source_type, not a new query path.
The duplication objection, answered: yes, body text is copied. But
emails.body_contentis HTML, and indexing markup makes every ticket "contain"div,spanandstyleβ so a stripped plaintext copy is needed anyway. Derived text is being stored either way; the only question is whether it lives in one table or three.
3. β οΈ tenant_scope β because NULL means opposite things
The single most important column to understand.
- A ticket with
tenant_id IS NULLbelongs to the default company βactiveTenantFilter()only includes it when the caller's active company is the default. - A knowledge article with
tenant_id IS NULLis shared with every company β the exact opposite.
A nullable tenant_id alone would therefore make a row's scope depend on which source_type produced it. Instead the meaning is resolved at index time and written down:
tenant_scope |
Means |
|---|---|
company |
visible to tenant_id only |
default |
the source's NULL meant the default company |
shared |
the source's NULL meant every company |
Use searchCorpusTicketScope() / searchCorpusArticleScope() rather than deciding this at each call site.
4. The permission predicate goes into the query
searchCorpusQuery() takes a scope structure, never SQL:
$scope = searchScopeForAnalyst($conn, $analystId, ['include_internal' => true]);
$res = searchCorpusQuery($conn, $query, $scope, ['limit' => 25]);
searchScopeToSql() is the only place that becomes SQL. Two reasons, and the first is not stylistic:
Post-filtering starves results. If you search first and remove what the caller may not see afterwards, the index returns its top N by relevance, you discard most of it, and hand back three rows β while hundreds the caller was entitled to never made the top N. It fails worst for the least privileged user, who is also the least likely to be the one testing it. A portal user, who can see only their own tickets, would get an empty page almost every time.
A SQL fragment in the interface welds it to MySQL. FreeITSM computes the predicate; the backend merely applies it. Replicating policy into a second system is dangerous β passing a computed filter to a dumb store is ordinary.
β οΈ An unspecified scope fails closed. Leave include_internal unset and internal notes are hidden, not exposed. tests/search/run.php asserts this deliberately rather than relying on it.
5. Query translation is not pass-through
β οΈ The single most surprising thing in this feature.
MySQL will not index words below innodb_ft_min_token_size. In boolean mode, requiring a term that is not in the index makes the entire query match nothing β so passing a user's words through verbatim as +word turns "printer in the office" into zero results.
searchParseQuery() therefore:
- reads the server's minimum at runtime (it is not the same everywhere β WAMP ships
0, stock MySQL is3) - drops terms below it and returns them in
dropped, so the UI can say "ignored: in, of" rather than showing an empty page - strips any boolean operators the user typed β they are ours to add, not theirs to inject
- adds a trailing wildcard to each term, the documented mitigation for MySQL having no stemmer:
printerthen finds printers. It over-matches on short stems, which is the accepted trade
Keep the user-facing language tiny β words, "phrases", -exclusion. The moment engine syntax reaches the UI, the engine has leaked into the product.
β οΈ Exclusion is per-document, not per-ticket. battery -swells still returns a ticket whose subject matches without the excluded word; a ticket only disappears when every one of its matching documents is excluded.
6. Two queries, in this order
1. rank GROUP BY ticket, MAX(score), LIMIT n
2. detail fetch the matching documents for just those tickets
The other order β fetch documents, collapse afterwards β reintroduces a top-N distortion, because the top 200 documents may collapse to a handful of tickets.
MATCH() must name exactly the columns of a full-text index, which is why there are two: ft_search_docs (title, body) and ft_search_docs_title (title). Searching titles alone is impossible without the second one.
There is no field weighting inside a MySQL full-text index. Ranking a subject hit above a body hit means composing the score by hand.
7. β οΈ InnoDB and uncommitted rows
Rows written inside an uncommitted transaction are invisible to MATCH ... AGAINST. The full-text cache is flushed at commit.
Consequences you will meet:
- An indexer cannot write a row and search for it in the same transaction.
- A test that inserts, searches and rolls back returns zero for everything β and will appear to pass its negative control while proving nothing.
searchBackfillRun()therefore commits in batches, and D007 writes a real probe row and deletes it in afinallyblock rather than using a rollback.
8. π©Ί Settings that break search silently
Three server variables decide which words exist at all. None errors when wrong.
| Variable | Stock | If wrong |
|---|---|---|
innodb_ft_max_token_size |
84 | Words longer than it are unindexed β authentication, configuration |
innodb_ft_min_token_size |
3 | Words shorter than it are unindexed β short codes, abbreviations |
innodb_ft_enable_stopword |
ON | Common words dropped from the index |
β οΈ WAMP ships max_token_size=10, in the [wampmysqld64] section of my.ini β not [mysqld], which is at the bottom of that file and unused. Every FreeITSM install on WAMP has long words silently unfindable until it is changed.
D007 reads all three and says the fix in plain English. Prefer running it to re-deriving any of this.
β οΈ After changing them, existing full-text indexes must be rebuilt.
9. Staying current β the dispatch subscriber
includes/search/indexer.php subscribes to WorkflowEngine::dispatch, alongside the notification bell, in its own try/catch. An index that cannot be written must not cost somebody their ticket.
It reindexes the WHOLE ticket, on purpose
Every interesting event rebuilds the ticket's subject row, all its message rows and all its note rows, rather than the one row that changed. That looks wasteful and is the right trade:
- Ordering-immune. Some paths announce before the opening message is written and some after. Indexing "the row that just changed" would need every caller to fire at exactly the right moment, forever.
- Self-healing. Anything a missed or failed event left stale is rewritten by the next event on that ticket.
- Cheap. A ticket is a handful of rows and every write is an upsert.
The listened-for events are deliberately few: ticket.created, ticket.note_added, ticket.reply_received, ticket.subject_changed, ticket.restored, ticket.deleted. A status or priority change moves no words about, so indexing on it would be pure cost.
β οΈ Announce AFTER the commit
Both new call sites dispatch once their transaction has committed. Two reasons, and the second is the one that bites: a rolled-back ticket must never announce itself, and InnoDB does not expose uncommitted rows to MATCH...AGAINST (Β§7), so an indexer running inside the transaction writes rows that the very next search cannot see.
π One definition, or results depend on how a ticket got indexed
searchIndexTicket() is the single description of what a ticket's corpus rows are, and searchBackfillRun() calls it β the backfill is now just "walk the tickets, batch, commit". The document construction used to be written out in both places.
If those two ever drifted, a search result would depend on whether a ticket happened to be indexed live or by a rebuild. That is close to undebuggable, because both paths look correct in isolation.
9a. Knowledge articles are indexed by a DIRECT CALL, not the seam
Deliberately different from tickets, and the difference is the point.
Tickets need the dispatch seam because three separate paths create them and share no code. Articles are the opposite: KnowledgeService is the only thing that writes knowledge_articles, so calling searchIndexArticle() from its five write points (save-update, save-create, archive, restore, purge) is both complete and obvious.
The events would also be the wrong hook. A newly created draft fires nothing at all β knowledge.published is withheld on purpose so a workflow does not announce a page nobody can open β yet a draft still needs indexing, because the palette deliberately shows analysts their own work in progress. "The text changed" and "tell people about it" are different questions for articles in a way they are not for tickets.
Two traps live in searchIndexArticle():
- π
NULLtenant_id on an article means shared with EVERY company β the exact opposite of a ticket, where it means the Default company. This is the entire reasonsearchCorpusArticleScope()exists separately fromsearchCorpusTicketScope(). It had been written and never called. - π
audiencemaps ontois_internalfailing CLOSED. Anything not explicitly opened tocustomerorpubliccounts as internal, so a future portal-facing search cannot leak an internal article by default.
Archived articles have their row removed rather than flagged, because the command palette has always excluded them β a search that disagreed with the rest of the product would be worse than one that finds less.
9b. β οΈ ticket.created did not fire for most tickets
Worth knowing, because the same trap will catch anything else that subscribes here.
Until #1070, ticket.created was dispatched from exactly one place β TicketsService::create(), the analyst path. The self-service portal and the inbound-email ingest both write their own raw INSERT INTO tickets and never went near that service, so the event fired for a minority of tickets on any real service desk.
The mailbox file even documents the mistaken belief: its ticket.reply_received dispatch is deliberately suppressed for the opening message because "ticket.created already covers that one". It did not, so a new emailed ticket announced nothing at all.
includes/ticket_events.php now builds the payload once from the stored row, and all three callers use it. Two fields are passed in rather than read, because they describe the act of creating rather than the ticket and legitimately differ per channel: created_by (analyst id, portal user id, or null for email β nobody signed in created it) and requester_email.
This changed behaviour beyond search. A workflow triggered on
ticket.createdthat had silently never run for emailed or portal tickets starts running. Check what is configured before updating a busy install.
9c. Adding a new source
- Add a
SEARCH_SOURCE_*constant inincludes/search/corpus.php. - Call
searchCorpusUpsert()with the righttenant_scope(Β§3) andis_internal. - Add it to
searchIndexTicket()if it hangs off a ticket, so live indexing and the backfill both pick it up. - Add a
part_*translation key in every complete locale, in the same commit.
Nothing in includes/search/search.php needs touching β that is the point of one corpus.
10. System β Search, and why the rebuild is chunked
system/search/index.php is the administrator-facing view of the corpus. Everything on it was already obtainable from D007 β Search corpus health, but D007 is a diagnostic: it lives under Debug Tools, prints a wall of environment detail, and is not where anyone looks to answer "is search working?".
The screen reports entries, tickets indexed against tickets total, articles indexed against articles total, when the index last changed, a breakdown by kind, and β importantly β the server's minimum word length, which is invisible from the application and is the single most common cause of a search that finds nothing.
β οΈ The rebuild runs in slices
api/system/search_rebuild.php indexes 200 tickets per call and returns where it stopped. The client passes last_ticket_id back as since_ticket_id and calls again until done.
The rebuild is the one operation here that scales with the size of the installation. A single request over years of tickets would run past max_execution_time and die halfway, leaving a partly-built index and no way to tell how far it got. Slicing also gives an honest progress bar rather than a spinner that cannot say whether anything is happening.
Three details that make it work:
searchBackfillRun()returnslast_ticket_idandtickets_remaining. Without the first the caller cannot resume; without the second it cannot show progress or know when to stop.- Knowledge articles ride on the final slice only, via the
articlesoption. They are not keyed by ticket id, so including them in every slice would re-index all of them every time. - The prune runs once, on the last slice. It is a single sweep over trashed tickets, not per-slice work.
Rebuilding is always safe to repeat: every write is an upsert on (source_type, source_id), so a slice that runs twice updates in place.
What this screen deliberately does NOT have
The design in Full-text search Β§8.4 puts two selectors here β document extraction and search backend. Neither is built, because each would offer exactly one option today. A dropdown with a single choice is furniture that implies a capability the product does not have. They belong here when there is something to choose between.
11. What is deliberately not built
Attachment textβ built, both tiers. See Attachment text extraction β Developer Guide:.docx/.xlsx/.pptxand text formats with no dependency, PDFs and OCR through Apache Tika. That page also explains why a pure-PHP PDF reader is the wrong turn, and the two queue bugs worth learning from.- The two selectors on System β Search. Extraction and backend, from Β§8.4. Deferred until either has a second option β see Β§10.
- A second search backend. The seam is built so it could be added; the argument for not writing one until an install needs it is Β§8.5 there.
- Portal search.
is_internalis stored, but whether requesters get content search at all is an open product question β nothing here should be read as sufficient to expose it.