Build with AI

How do you search your own documents with embeddings, and prove that it works?

Written as of · Published · Updated · By Robert Scales, with AI tools · 4 min read

Short answer

Turn each passage and each question into an embedding with a small open model, return the closest passages, then test it with questions whose right answer you know. On a small test handbook, keyword search put the right passage first for 21% of reworded questions and embeddings did for 79%, while French questions needed a multilingual model.

An embedding is a list of numbers that places a piece of text on a map of meaning, as this introduction explains. This project skips the theory and does the part that often gets skipped: building a labelled test, so you know if your search works before putting it in front of anybody.

The documents are a fictional community centre's handbook, Harrow Bay: 24 short passages on hours, fees, parking and so on. The questions come in three sets, each with a known right passage: 12 that share words with the document, 14 written on purpose to avoid its words, and 12 in French. The script 08_search_documents.py holds all of it. The models are free downloads, licensed Apache 2.0, and everything ran on the CPU.

Search in ten lines

import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
passages = ["The pool closes at 21:00 on weekdays.",
            "Households with low income can apply for a reduced rate.",
            "Only service animals may enter the building."]
vectors = model.encode(passages, normalize_embeddings=True)

question = "I can't afford the yearly price, is there help?"
scores = vectors @ model.encode([question], normalize_embeddings=True)[0]
for i in np.argsort(-scores):
    print(round(float(scores[i]), 2), passages[i])

normalize_embeddings=True brings every vector to length one, so a plain multiplication gives the cosine similarity, a closeness score that is 1 for identical directions. That is the whole of semantic search at this size. For millions of passages you would add an index, a structure that finds neighbours quickly. For a few thousand, a plain loop like this is enough.

The companion script also searches a folder of your own .txt and .md files:

python 08_search_documents.py --ask "how long do you keep lost items?" --folder my_notes/

The measured comparison

Top 1 means the right passage came first. Top 3 means it was in the first three, which matters if a person or a language model reads the results. BM25 is a classic keyword-scoring formula, written in the script in about fifteen lines.

QuestionsBM25 keywordsall-MiniLM-L6-v2paraphrase-multilingual-MiniLM-L12-v2
Sharing words with the document (12), top 183%100%100%
Avoiding its words (14), top 121%79%64%
Avoiding its words (14), top 329%93%93%
French questions (12), top 18%33%83%
French questions (12), top 325%58%100%
Encoding speed, milliseconds per passage2.09.1

Measured here: 24 passages, 38 questions, Apple M5 Max CPU, sentence-transformers 6.2. With so few questions, one question moves a figure by 7 or 8 points. Read the large differences only. A larger test on your own documents will tell you more than this table.

Two practical results stand out for a bilingual organization. The English model cannot serve French users: it fell to 33%. And the multilingual model gave up some English quality in exchange for working across languages.

Two results that went against expectations

Combining keyword and meaning search is often recommended. Here it did not help. Merging the two rankings with reciprocal rank fusion, which adds up scores based on position in each list, scored 62% top 1 with the first model, below the 88% for embeddings alone. Keywords were so weak on the reworded questions that they dragged good results down. Hybrid search helps most when keywords find things embeddings miss, such as part numbers and rare names, and this handbook had few of those. Test it on your data before you adopt it.

Passage size also behaved in an interesting way. The script joined the handbook into one long text and cut it blindly, as a naive pipeline would.

Words per chunkChunksAnswer in top 1Answer in top 3Words to read for top 3
104981%100%30
202585%96%60
401373%92%120
80769%100%240
160469%96%480
320250%100%960

The "100%" for very large chunks is hollow: with two chunks, the top 3 is the whole handbook. The last column shows the actual cost. Smaller chunks did well because this handbook is made of short, self-contained passages. Long reports whose sentences depend on earlier paragraphs will behave differently. Also note that the model cuts off long input: by default, text past 256 word pieces is ignored without any error.

Try it: Write 20 questions a real person might ask about your own notes, mark the right file for each, and calculate the top 1 and top 3 before you change any settings.

How do you stop it from answering when nothing matches?

Search always returns something, even for a question your documents cannot answer. Ten off-topic questions, like how to fix a faucet, show the problem. The way out is a minimum score, chosen from your own numbers.

Minimum score, all-MiniLM-L6-v2Good questions still answeredOff-topic questions wrongly answered
0.2100%30%
0.392%10%
0.481%0%
0.550%0%

The multilingual model needs a different setting; at 0.4 it answered 77% of good questions and 10% of off-topic ones. Scores from one model mean nothing for another, so recompute this table each time you change model.

Which row to pick depends on what a wrong answer costs. For a staff search tool, answering too much is fine, since a person looks at it. For a public assistant, prefer "I could not find this" and a link to a human. Once a language model writes answers from the passages, you have a second system to test, and this article on evaluating RAG covers the rest. If you are weighing search against training a model, the next project measures that choice directly.

Download the companion code for this series (zip), with every script and the recorded results.

Sources

  1. Semantic Search — Sentence Transformers
  2. all-MiniLM-L6-v2 model card — Hugging Face / Sentence Transformers
  3. paraphrase-multilingual-MiniLM-L12-v2 model card — Hugging Face / Sentence Transformers

From the Guide to Artificial Intelligence, by Robert Scales, published by Holistic Hybrid Inc. Share and adapt it under CC BY-NC-SA 4.0: credit the guide, link to https://guidetoartificialintelligence.com/en/learn/search-own-documents-embeddings-measured/, no commercial use without permission, and keep the same licence.

Get the free Guide to Artificial Intelligence

Sign up for the newsletter and receive the free guide: what changed in AI, one lesson, one prompt to try. Unsubscribe in one click.

How we handle your address

Back to top

Display

Your choices are saved in this browser only. No data is sent.

Text size
100%
Colours
Reading
More settings
Accessibility statement

Get the app

Add the guide to your home screen. It opens like an app, and the pages you have read stay available offline.

Read without a connection: download the articles

iPhone and iPad (Safari, Chrome, Edge)

  1. Tap the Share button (the square with an arrow). In Chrome and Edge, it is in the address bar or the menu.
  2. Choose “Add to Home Screen”.
  3. Tap “Add”.

Android (Chrome, Edge, Samsung Internet, Firefox)

  1. Open the browser menu (⋮ or ☰).
  2. Choose “Install app” or “Add to Home screen”.
  3. Confirm with “Install”.

Computer (Chrome, Edge)

  1. Click the install icon at the right end of the address bar, or open the browser menu (⋮ or …).
  2. In Chrome, choose “Cast, save and share”, then “Install page as app”. In Edge, choose “Apps”, then “Install this site as an app”.
  3. Click “Install”.

Mac (Safari)

  1. Open the File menu.
  2. Choose “Add to Dock”.
  3. Click “Add”.

Firefox on a computer can’t install a site as an app. Use Chrome, Edge or Safari instead.