I asked my own document server for invoice INV-2024-0781 using its semantic search. It ranked a fibre-patch-cord invoice first, with a score of 0.511. The only document that actually contains that number came third, at 0.508. Four of the five results don’t contain the number at all.
That isn’t a bug. Vector search always returns the nearest neighbours, and it has no way to say “that exact number isn’t here.”
The server is pdf-mcp, a personal project. It’s an MCP server that indexes each customer’s PDFs (contracts and invoices) into Qdrant, so an AI client can search them and get answers with page citations. This article is about the second index I added next to the vectors, and why it’s there.
Why embeddings are the wrong tool for identifiers
An embedding model turns a chunk of text into a vector that represents what the text is about. That’s exactly what you want for a question like “what is the notice period for termination?” The clause that answers it may not use the words “notice period” at all. Vector search still finds it, because the meaning is close.
An invoice number is the opposite kind of query, because it carries no meaning to capture. Invoice pages are near-identical: same header, same terms, same layout. Whatever the embedding captures about a page, the number is a small part of it, which is why the scores above sit so close together.
Formatting makes it worse: the same number can appear as INV-2024-0781, INV/2024/0781 or INV20240781, and OCR adds stray spaces. For this lookup I wanted exact character matching, not similarity.
What I built
pdf-mcp already stores every page’s extracted text in a SQLite registry. That includes the PDF text layer, OCR output for scanned pages, and the vision model’s descriptions of charts and figures. So I added a SQLite FTS5 full-text table over a normalised copy of that text, using the trigram tokenizer:
self._db.execute(
"CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5("
"norm, customer UNINDEXED, doc_id UNINDEXED, page UNINDEXED, tokenize='trigram')"
)
A trigram index stores every three-character sequence, so it can match any substring, not just whole words. That matters because identifiers don’t have clean word boundaries.
Page text and the query both go through the same normalisation function:
_ID_SEP = re.compile(r"(?<=[0-9A-Za-z])[ \t]*[-/_.:#][ \t]*(?=[0-9A-Za-z])")
def normalize_ref(text: str) -> str:
return _ID_SEP.sub("", text or "").lower()
It removes separators (- / _ . : #) that sit between letters or digits, including the spaces OCR tends to add around them, and then lowercases everything. INV-2024-0781, inv/2024/0781 and INV20240781 all become inv20240781. The normalised query is matched as a phrase, so a partial number like 0781 also finds INV-2024-0781.
The normalised text is only used for matching. The snippet in each result is cut from the original page text, using a pattern that allows an optional separator between each character. So you see INV/2024/0781 exactly as it was printed.
Some smaller details:
- At least three characters. A trigram index can’t look up anything shorter than a trigram, so
find_referencerejects shorter input with a clear error. - The index stays in sync. Saving a document’s pages rewrites its rows in both the page table and the FTS table under one lock, and deleting a document removes it from both. On startup, the index is rebuilt if its row count doesn’t match the page table or the normalisation version has changed.
- Ranking. Whole-identifier matches come first.
0781counts as whole insideINV-2024-0781, where the separator marks a boundary, but not inside10781. After that come documents with the reference in the filename, then documents with more occurrences. Each match lists the customer, document, pages, occurrence count, a snippet, and a link that opens the PDF at the first matching page.
Scoped by customer, with a separate global tool
Every query tool in pdf-mcp takes a customer_code and only touches that customer’s data. Exact lookup follows the same rule, as two separate MCP tools:
find_reference(customer_code, reference)searches one customer’s documents. It’s always available.find_reference_all_customers(reference)searches every customer. It’s only registered whenPDF_MCP_ALLOW_GLOBAL_SEARCH=true. Without the flag, a client doesn’t see the tool at all.
The command-line pdf-mcp ref searches all customers by default, because it’s the operator’s tool, not the agent’s.
The two indexes aren’t merged. ask and search still go to Qdrant only. Instead, the server’s instructions tell the client LLM to use find_reference for invoice, PO and contract numbers, because semantic search is unreliable for those. The agent picks the tool.
Here’s the same question both ways, on the repo’s synthetic sample documents:
pdf-mcp find ACME "INV-2024-0781"(vectors, bge-m3): five documents, scored 0.511, 0.511, 0.508, 0.475 and 0.462. The right one is third.pdf-mcp ref INV-2024-0781 --customer ACME(exact index): one document, the remittance that contains the number.
How I know it works
The tests in tests/test_reference.py run with a deterministic fake embedder and LLM, embedded Qdrant and real Tesseract. They also run in GitHub Actions on every push. The fixture creates a GLOBEX invoice that prints its number as INV/2024/0781, and an ACME remittance advice that mentions INV-2024-0781 with hyphens. Against those, the tests check that:
INV-2024-0781,inv/2024/0781,INV20240781and2024-0781each find both customers’ documents.0781, scoped to GLOBEX, returns only the GLOBEX invoice.7781finds a scanned invoice whose number exists only in the OCR output.INV-9999-0000returns zero documents. That’s the “not found” answer vector search can’t give.07is rejected for being too short.- After the GLOBEX invoice is deleted, only ACME matches.
- Dropping the FTS table, or changing the stored normalisation version, triggers a rebuild on the next start.
find_reference_all_customersis missing by default and appears once the flag is set.INV-2024-0718andINV-2024-0781never return each other’s invoices.- A partial
0781still finds “delivery note 10781”, but a realINV-2024-0781match ranks above it.
That last test exists because writing this article caught a bug. I generated a synthetic PDF for each claim; they’re now in the repo’s samples folder. A partial search for 0781 ranked a file named ACME_delivery_note_10781.pdf above the real match, because filename matches used to come first. Ranking now checks for a whole-identifier match before anything else.
For contrast, the README’s sample questions include “Which invoice billed R750 servers?” That one goes through ask (vectors plus the LLM), which is the right path, because it’s a question about meaning. The exact index doesn’t replace that path. It handles the queries vectors were never good at.
If you’re on PostgreSQL
I haven’t built this part, but pg_trgm does the same job: a GIN index with gin_trgm_ops on a normalised column makes LIKE '%inv20240781%' fast and exact. It also adds fuzzy matching, which could catch an OCR misread like INV-2O24-0781, shown as a “did you mean” suggestion. With pgvector alongside it, both searches share one WHERE customer = ... filter.
The general lesson
Vectors handle questions about meaning well. Identifiers are different: their value is the exact characters, and a near-miss is a wrong answer. Give them their own exact index. Normalise the stored text and the query with the same function. Make “not found” a possible result. Then tell the agent which tool to use for which kind of question. Hybrid retrieval doesn’t have to start as a fused ranking pipeline. It can start as two tools with clear jobs.
Repo and a limitation
The code is at github.com/perdhevi/pdf-mcp, with sample PDFs and the verification commands in the README. That link points to the version this article describes.
One limitation to be clear about: pdf-mcp has no authentication. customer_code keeps customers’ data apart in queries, but it is not access control. Any client that can reach the HTTP endpoint can call any customer’s tools and download their PDFs. It’s built for a trusted machine or network.
Permissions are the next problem I’m working on, in a separate project: permission-aware RAG, where retrieval only returns what the person asking is allowed to see. I’ll write that up when it’s done.



















