Semantic search
From raw text to a ranked result: split into passages, index, query, and know when the result is no good.
Updated on Aug 10, 2026
Keyword search finds what was written. Semantic search finds what was meant — even in other words. This page builds one, from raw text to a ranked result, with the numbers the API actually returned.
A base of support answers, written in Portuguese. The user types "quero meu dinheiro de volta" — I want my money back — a sentence with not a single word in common with the right answer. A LIKE in the database returns zero rows. Search by meaning returns:
| Score | Passage from the base |
|---|---|
| **0.5486** | Cancelamentos podem ser feitos em até 7 dias após a compra, com reembolso integral. |
| 0.5054 | Emitimos nota fiscal eletrônica automaticamente após a confirmação do pagamento. |
| 0.4854 | Aceitamos cartão de crédito, Pix e boleto bancário. |
| 0.4199 | O prazo de entrega padrão é de 3 a 5 dias úteis para todo o Brasil. |
"Dinheiro de volta" and "reembolso integral" — money back and full refund — do not look alike as text; they look alike as subject matter. That is the whole point.
- 1
Split into passages
Each passage has to make sense on its own — it is the passage that gets returned, not the whole document.
- 2
Generate the embeddings once
In a batch, and store the vectors alongside the text and its source. Only what changes has to be redone.
- 3
At query time, embed the question
Same model, same dimension. One call, a few thousandths of a cent.
- 4
Rank by similarity
Dot product against every stored vector, from highest to lowest.
- 5
Cut off what is no good
Always returning the top three is wrong: sometimes the answer is not in the base.
The base holds eight support answers. The code embeds all of them in one call, embeds the question in another, and ranks:
import os, requests
CHAVE = os.environ["HINOW_API_KEY"]
MODELO = "hinow/hiembed"
BASE = [
"Para trocar a senha, abra Configurações › Segurança e clique em Redefinir senha.",
"O prazo de entrega padrão é de 3 a 5 dias úteis para todo o Brasil.",
"Aceitamos cartão de crédito, Pix e boleto bancário.",
"Cancelamentos podem ser feitos em até 7 dias após a compra, com reembolso integral.",
"O aplicativo está disponível para Android e iOS.",
"Emitimos nota fiscal eletrônica automaticamente após a confirmação do pagamento.",
"Nosso suporte atende de segunda a sexta, das 9h às 18h.",
"Para acompanhar o pedido, use o código de rastreio enviado por e-mail.",
]
def embeddings(textos):
resposta = requests.post(
"https://api.hinow.ai/v1/embeddings",
headers={"Authorization": f"Bearer {CHAVE}"},
json={"model": MODELO, "input": textos},
)
resposta.raise_for_status()
itens = sorted(resposta.json()["data"], key=lambda i: i["index"])
return [item["embedding"] for item in itens]
# Os vetores já vêm normalizados: o produto escalar é a similaridade de cosseno.
def similaridade(a, b):
return sum(x * y for x, y in zip(a, b))
indice = list(zip(BASE, embeddings(BASE))) # gere uma vez, guarde
pergunta = embeddings(["quero meu dinheiro de volta"])[0]
ranking = sorted(
((similaridade(pergunta, vetor), texto) for texto, vetor in indice),
reverse=True,
)
for score, texto in ranking[:3]:
print(f"{score:.4f} {texto}")const CHAVE = process.env.HINOW_API_KEY!;
const MODELO = "hinow/hiembed";
const BASE = [
"Para trocar a senha, abra Configurações › Segurança e clique em Redefinir senha.",
"O prazo de entrega padrão é de 3 a 5 dias úteis para todo o Brasil.",
"Aceitamos cartão de crédito, Pix e boleto bancário.",
"Cancelamentos podem ser feitos em até 7 dias após a compra, com reembolso integral.",
"O aplicativo está disponível para Android e iOS.",
"Emitimos nota fiscal eletrônica automaticamente após a confirmação do pagamento.",
"Nosso suporte atende de segunda a sexta, das 9h às 18h.",
"Para acompanhar o pedido, use o código de rastreio enviado por e-mail.",
];
async function embeddings(textos: string[]): Promise<number[][]> {
const resposta = await fetch("https://api.hinow.ai/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${CHAVE}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: MODELO, input: textos }),
});
if (!resposta.ok) throw new Error(await resposta.text());
const { data } = await resposta.json();
return [...data].sort((a, b) => a.index - b.index).map((item) => item.embedding);
}
// Os vetores já vêm normalizados: o produto escalar é a similaridade de cosseno.
const similaridade = (a: number[], b: number[]) =>
a.reduce((soma, valor, i) => soma + valor * b[i], 0);
const vetores = await embeddings(BASE); // gere uma vez, guarde
const [pergunta] = await embeddings(["quero meu dinheiro de volta"]);
const ranking = BASE.map((texto, i) => ({ texto, score: similaridade(pergunta, vetores[i]) }))
.sort((a, b) => b.score - a.score);
for (const { score, texto } of ranking.slice(0, 3)) {
console.log(score.toFixed(4), texto);
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
)
const modelo = "hinow/hiembed"
var base = []string{
"Para trocar a senha, abra Configurações › Segurança e clique em Redefinir senha.",
"O prazo de entrega padrão é de 3 a 5 dias úteis para todo o Brasil.",
"Aceitamos cartão de crédito, Pix e boleto bancário.",
"Cancelamentos podem ser feitos em até 7 dias após a compra, com reembolso integral.",
"O aplicativo está disponível para Android e iOS.",
"Emitimos nota fiscal eletrônica automaticamente após a confirmação do pagamento.",
"Nosso suporte atende de segunda a sexta, das 9h às 18h.",
"Para acompanhar o pedido, use o código de rastreio enviado por e-mail.",
}
func embeddings(textos []string) ([][]float64, error) {
corpo, _ := json.Marshal(map[string]any{"model": modelo, "input": textos})
req, _ := http.NewRequest("POST", "https://api.hinow.ai/v1/embeddings", bytes.NewReader(corpo))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HINOW_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var r struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
return nil, err
}
vetores := make([][]float64, len(r.Data))
for _, item := range r.Data {
vetores[item.Index] = item.Embedding
}
return vetores, nil
}
// Os vetores já vêm normalizados: o produto escalar é a similaridade de cosseno.
func similaridade(a, b []float64) float64 {
var soma float64
for i := range a {
soma += a[i] * b[i]
}
return soma
}
func main() {
vetores, err := embeddings(base) // gere uma vez, guarde
if err != nil {
panic(err)
}
pergunta, err := embeddings([]string{"quero meu dinheiro de volta"})
if err != nil {
panic(err)
}
type achado struct {
score float64
texto string
}
ranking := make([]achado, len(base))
for i, texto := range base {
ranking[i] = achado{similaridade(pergunta[0], vetores[i]), texto}
}
sort.Slice(ranking, func(i, j int) bool { return ranking[i].score > ranking[j].score })
for _, r := range ranking[:3] {
fmt.Printf("%.4f %s\n", r.score, r.texto)
}
}The output, with the numbers the API returned:
0.5486 Cancelamentos podem ser feitos em até 7 dias após a compra, com reembolso integral.
0.5054 Emitimos nota fiscal eletrônica automaticamente após a confirmação do pagamento.
0.4854 Aceitamos cartão de crédito, Pix e boleto bancário.Two API calls and twenty lines of code. For eight passages, an in-memory loop does the job; from a few tens of thousands upward, it is time for a vector database — the logic stays exactly this one.
| Query | First result | Score |
|---|---|---|
| "esqueci como entrar na minha conta" | Para trocar a senha, abra Configurações › Segurança… | 0.6155 |
| "posso pagar com pix?" | Aceitamos cartão de crédito, Pix e boleto bancário. | 0.7096 |
| "quando chega minha encomenda?" | O prazo de entrega padrão é de 3 a 5 dias úteis… | 0.6718 |
| "I forgot my password" | Para trocar a senha, abra Configurações › Segurança… | 0.5752 |
The last row is not a detail: the question is in English, the whole base is in Portuguese, and the right passage came first. The same index serves users in different languages with no translation step in between.
The number is neither a probability nor a percentage of correctness — it is the distance between two meanings, in this base, with this model. The bands measured here work as a starting reference:
| Band | What it usually is |
|---|---|
| above 0.90 | Paraphrase or duplicate — the same content written another way |
| 0.55 to 0.75 | Genuinely related: the result the user wanted |
| 0.45 to 0.55 | Same domain, different question — usually disappointing |
| below 0.45 | Unrelated |
Calibrate the cut-off with your own base
These thresholds move with the size of the passages, the subject, and the model — and they cannot be compared across different models. Run thirty real questions, look at the score of the right result and of the first wrong one, and cut in between. Without a cut-off, the search always returns something: including when the answer does not exist in the base.
This is the decision that changes search quality the most, and it happens before any API call.
- One passage, one idea. The passage is what goes back to the user — or what enters the model's context. A whole chapter dilutes the subject and drags the score down; a lone sentence loses the context that gave it meaning.
- From 200 to 500 tokens is usually the balance point for documentation and knowledge bases. The technical ceiling is much higher — around 8,192 tokens — but getting close to it makes the result worse.
- Cut by structure, not by count. Title, section, paragraph. Breaking mid-sentence produces a vector about nothing.
- Repeat a little of the neighbour (one or two sentences of overlap) when the text runs continuously, so you do not lose the idea that crosses the boundary.
- Carry the context along. Storing "Refund policy › Deadlines" in front of the passage helps the vector and helps whoever reads the result.
Start simple and scale with the volume:
- Up to a few thousand passages — a list in memory and the loop above. That is enough for most products in their first year, and it is easy to debug.
- Tens of thousands and up — a vector database, with an approximate index. The similarity math stays the same; what changes is not scanning everything on every query.
- Not wanting to maintain any of it — HINOW already offers the ready-made path: knowledge bases handle ingestion, splitting, indexing, and search, and you call a single API.
Always store, alongside the vector: the source text, the document reference, the model id, and the dimension. Without the model and dimension recorded, the day you switch models turns into archaeology.
- Exact filter — status, category, tax id, order number. That is
WHERE, and the database does it better, cheaper, and without error. - Number, date, and range — "orders above R$ 500 in March" is not a subject, it is a condition.
- Identifier and code — SKU, version, file name. The vector finds "similar", and similar is wrong here.
- Negation — "plans that do not include support" comes out almost identical to "plans that include support".
In practice the two coexist: an exact filter to narrow the set, semantic search to rank what is left. And when the user types a term that exists literally in the text, word search is still unbeatable — merging the two lists usually pays off more than choosing one.
Put together a set of thirty to fifty real questions — the ones that arrive at support, not the ones you imagined — and note which passage should come first. Measure two things: how often the right passage lands at the top, and how often it lands in the top five.
That number is what tells you whether changing the passage size, the cut-off, or the model improved anything. Without it, every change looks good.

