Search, reports and document generation - from an inverted index to a PDF

How full-text search works inside - the inverted index, stemming and scoring - where a search should run, and how reports are built once, kept as snapshots, sent on a schedule and written as Excel and PDF files without giving away small groups.

In short

Search and reporting look like two jobs, but they share one idea: find the right records quickly, then present them so people can act. Full-text search works by building an inverted index - a map from every word to the documents that contain it - and scoring the matches. Reports work by turning records into one document shape of headline numbers, tables and charts, which can be shown on a screen, kept as a snapshot, sent on a schedule or written as an Excel or PDF file. In {{brand}}, Help is searched in the browser with MiniSearch, records are searched live in the database, and every file is written from the same report document. Wherever a number describes people, a small-group rule stops it from revealing one person.

After this lesson you can Explain how an inverted index and relevance scoring work, choose where a search should run - browser, database or search engine - design a report pipeline with snapshots, comparisons and schedules, write correct Excel and PDF files from .NET, and keep group totals from exposing one person.

Words you need

How full-text search works inside

Every full-text engine - from a few kilobytes of JavaScript to an Elasticsearch cluster - does two things: it indexes documents once, and it answers queries from that index. Learn the two flows and you can read any search design.

Three tiny documents and the index built from them, with the stop word a dropped and every word stemmed:

The query sharing board becomes shar and board. With AND, only D1 holds both. With OR, D1 comes first because it holds two terms, then D2 and D3 with one each. No document was read to find this - only two posting lists.

How results are scored

BM25 is the scoring formula behind Lucene, and so behind Elasticsearch and OpenSearch; MiniSearch uses a close variant. For every query word it combines three facts: how often the word appears in the document (term frequency), how rare it is across all documents (inverse document frequency), and how long the document is.

A worked example. The index holds 1,000 documents. The word webhook appears in 5 of them, board in 300. IDF(webhook) = ln(1 + 995.5 / 5.5), about 5.2. IDF(board) = ln(1 + 700.5 / 300.5), about 1.2. In a document of average length with k1 = 1.2, one mention of webhook scores about 5.2, while five mentions of board score only about 2.1 (1.2 times a repeat factor of 1.8), because repeats help less and less. One rare word outweighs five common ones.

PostgreSQL ranks differently PostgreSQL's ts_rank and ts_rank_cd score each document on its own - how often and how close together the words appear, and in which weighted field - with no statistics about the other documents, so there is no IDF. That is fine for ordering a short list of candidates you already found, and one reason very large collections move to a search engine.

Three places a search can run

The options in the market

The three green cards are three separate decisions, one per kind of content: records found by name, passages ranked for a question, and static help text searched in the browser.

The second big decision is how reports are made. Every module can write its own export, or one engine can build them all.

What {{brand}} chose, and why

The trade-off accepted: record search has no typo tolerance, synonyms or facets - a search for paymnt finds nothing. That is acceptable because people search for names they know, and results arrive as they type, so a typo is seen and fixed at once. A search engine would be the right call for millions of documents, long prose, typo-tolerant product catalogues or log search - and then every indexed document would have to carry its permissions.

Permissions decide the architecture The hard part of server-side search is not ranking - it is who may see what. A separate index must copy every permission and update it on every change. Searching live, as the person, inside each module gets permissions right by construction.

Client-side search with MiniSearch

MiniSearch is a full-text engine of a few kilobytes for JavaScript, MIT licensed. You give it documents with fields; it keeps an inverted index in memory - a radix tree, so prefix and fuzzy lookups are fast - and scores matches with BM25. Everything else - which words count, how typos are forgiven, what must match - is configuration.

Fold text the same way everywhere Lower-casing alone is not enough across languages: toLocaleLowerCase in a Turkish locale turns I into ı, and İ lower-cases to i plus a combining dot, so results would depend on the reader's language. Plain lower-casing also leaves ə, ß and ø unchanged. Normalize to NFD, strip the combining marks, map the few special letters yourself - and use the folded form only for matching, never for showing.

Learn from searches that found nothing Log the searches that found nothing, so your content can grow where people looked in vain. Strip anything that looks like an email, a number or a token before storing, and list a term only when several different people searched for it.

Server-side search: ask every owner, as the person asking

Records live in the database, change every second, and each one has its own rules about who may open it. So {{brand}} does not copy them into an index. The command palette sends the typed term to the server, and the server asks every module to search its own records, as the person asking, in the workspace they stand in.

Escape LIKE wildcards In LIKE, % and _ are wildcards. A person who types 50%_off would otherwise match almost every row. Escape them - and the escape character itself - before building the pattern, and always pass the term as a parameter, never as SQL text.

Each module searches in parallel, each in its own scope: a database session such as EF Core's DbContext is not safe to share between threads. A module that throws is logged and left out, so one broken part never empties the whole list.

Never send what was pasted A pasted cURL command or web address may hold a token. Send only plain typed text as a search term, so a secret never lands in a server's access log.

PostgreSQL full-text search in practice

PostgreSQL turns text into a tsvector - a sorted list of stems with their positions and weights - and a question into a tsquery. The @@ operator says whether they match, and the ranking functions say how well. A text search configuration such as english decides the stemmer and the stop words.

Ranking text you did not store to_tsvector works on parameters as well as columns: send a few dozen candidate passages with the question and get one score per passage back. {{brand}} ranks the assistant's passages this way, at question time, so no index holds a copy of anyone's data. plainto_tsquery joins words with AND; to let any word count, replace its & with |.

The report provider pattern: build once, show everywhere

Without a pattern, every module writes its own Word export, its own spreadsheet and its own screen - and they drift until the file and the screen disagree. The report provider pattern splits the work in three: one document shape, one provider per report in the module that owns the data, and one engine that runs, keeps, compares, summarizes, exports and schedules any of them.

Snapshots, comparisons and schedules

A snapshot is the whole report document stored as JSON when it ran, with its headline numbers copied beside it so a list of past runs is cheap to show. A fingerprint - a hash of exactly what was stored - lets a copy someone was sent be matched to the kept one. Links to records are better worked out for each reader when the report is shown, so someone who lost access reads the words without a way in.

Keep the wall clock, not the offset Store 07:00 Europe/Berlin, not 05:00 UTC: a schedule kept in UTC moves by an hour twice a year. Decide on purpose what a time the clocks skip in spring does (run at the first minute that exists) and one they show twice in autumn does (run the first time).

Check again at every read A report is built as one person and then travels: it is kept, reopened, exported and mailed. A careful design stores the rules about who may read it with the copy and asks them again for each reader at the moment they read. A rule nobody can answer should mean nobody reads - fail closed. And a copy that lists people one by one should not be kept longer than it is needed.

Generating Excel and PDF files

An .xlsx file (and a .docx) is a ZIP of XML parts - the Office Open XML standard. You can write that XML through Microsoft's Open XML SDK, or use a library that gives you workbooks, sheets, cells and styles. A PDF is different: it places every glyph at an exact position and has no idea of a table or a page break. Something must measure the text and decide what fits - that is the layout engine.

Formula injection A cell that starts with =, +, - or @ can run as a formula when someone opens or edits the file - a crafted =HYPERLINK can send data to an outside address. Store such text as text: a quote prefix in Excel, a leading apostrophe in CSV.

QuestPDF describes a document in C#, much like a UI layout: a document holds pages; a page has a header, content and footer; content is built from containers - Column, Row, Table, Text, Image. Its layout engine then measures everything and paginates: a table's header row repeats on each new page, a heading is kept with its first lines, and the footer can say page X of Y.

Fonts decide whether letters appear A PDF embeds the fonts it uses. If the machine making the file has no font with Cyrillic or Azerbaijani letters, those characters print as empty boxes. So the writer must have such a font, and a missing font or letter should fall back to the next font instead of failing the download.

Licences are part of the choice ClosedXML, the Open XML SDK and MiniSearch are MIT. QuestPDF is free under its Community licence for individuals, non-profits, open-source projects and companies under USD 1M gross yearly revenue, and paid above that; the licence is set in code before the first document. EPPlus and iText need a paid licence for most commercial use. Read a library's licence before it reaches production.

Word and PDF writers in {{brand}} take the same calls - title, heading, paragraph, table, chart - so a document is written once and the reader picks the file. Charts are drawn once as pictures by a chart library (ScottPlot) for Word, PDF and Excel, while the web draws the same data live. Very long tables are cut, with a line saying how many rows were left out.

Files also flow the other way: someone uploads a PDF and a program needs its text. Inside, a PDF is a set of numbered objects, and each page has a content stream of drawing commands, roughly "use font F1 at 11 points, move to x 72, y 700, show glyphs 72 101 108 108 111". There are no paragraphs, lines or even spaces. A library must map glyph codes back to Unicode through the font's encoding (its ToUnicode table), then guess word breaks from the gaps between letters and line breaks from changes in height. PdfPig, written in plain C#, does this: PdfDocument.Open, then page.Text for the page's text or page.GetWords() for words with their bounding boxes.

{{brand}} reads uploaded PDFs with PdfPig because it adds nothing to install next to the server, its licence is permissive, and it also lets the tests read back the PDFs the product generates to prove they say what they should. The trade-off is the heuristics: text is returned in the order the file draws it, which is not always the order a person reads. Two rules matter for any reader. A PDF from a stranger is untrusted input, so bound its size, the pages you read, the text you keep and the time you spend, and treat a file past a bound as unreadable. And say plainly when a file has no text layer, instead of returning an empty answer as if all were well.

Privacy in totals: small groups and differencing

A total feels anonymous, but it is not automatically. In a team of two, each person can work out the other's hours from the team total. k-anonymity is the classic answer: every value you publish must stand for at least k people. A common choice is k = 3, 5 or 10; the right k depends on how sensitive the figure is. {{brand}} applies such a minimum and goes further against the subtler differencing attacks.

Absent, not zero A hidden value must be absent. A zero is a lie - and it can be subtracted like any other number. Say why it is missing, so the reader trusts the rest.

Try it yourself: a search box and a PDF in an hour

Free tools only: Node.js for the search, the .NET SDK for the PDF. Build both halves of this lesson and watch each rule change the result.

What you should see when you run try-search.ts:

Common mistakes

Interview questions

Related