-
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/backfill.php |
Rebuild the corpus from tickets / emails / ticket_notes
|
| π§° | scripts/search_backfill.php |
CLI wrapper β --limit, --prune, --stats
|
| π | api/tickets/search_content.php |
The inbox's content search. Builds a scope; decides nothing itself |
| π₯οΈ | 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.
- Add a
SEARCH_SOURCE_*constant inincludes/search/corpus.php. - Call
searchCorpusUpsert()with the righttenant_scope(Β§3) andis_internal. - Extend the backfill if the source pre-dates the change.
- 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.
- 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.
- Indexing as tickets arrive. The backfill is run by hand.
- 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)