-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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/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
|
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.
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.
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.
include_internal unset and internal notes are hidden, not exposed. tests/search/run.php asserts this deliberately rather than relying on it.
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.
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.
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.
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.
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 |
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
- Attachment text. The extractor tiers, the caps and the hostile-input handling are all designed on Full-text search Β§3.2, Β§6.4 and Β§8.2, and none of it exists.
- 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.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)