Skip to content

18. Finding cards by meaning

filter answers a question with a yes or no per card. Retrieval answers a question in words with a ranking: every card that matches, best first, each with a score. HQL has two retrievals, each an extension a program imports.

Lexical retrieval

lexical matches the words of a query against the words of each card. It needs nothing built in advance:

import lexical
cards | lexical("desert spice") | take(3)
[dune (0.2928), frankenstein-inspires-dune (0.2778), frankenstein (0.2471)]

import lexical makes the extension's step available to the program. Without it, the name is not a step, and the error says which import provides it:

cards | lexical("desert spice")
check error[unknown-name]: `lexical` is provided by the `lexical` extension: write `import lexical`
 --> <expression>:1:9
  |
1 | cards | lexical("desert spice")
  |         ^^^^^^^^^^^^^^^^^^^^^^^

A ranking holds hits

A ranking is a sequence of hits. A hit holds the card it retrieved, as value, and its score:

import lexical
cards
| lexical("desert spice")
| take(2)
| map(h => {card: h.value.name, score: h.score})
[{card: "dune", score: 0.2927674902397191}, {card: "frankenstein-inspires-dune", score: 0.2777641653663699}]

A score is evidence that a card is relevant to the query, not relevance itself, so every hit carries the retrieval that produced it — the model, the index, the metric, and whether the search was approximate:

import lexical
ranked = cards | lexical("desert")
{model: ranked.retrieval.model, metric: ranked.retrieval.metric, approximate: ranked.retrieval.approximate}
{approximate: false, metric: "cosine", model: "hql.hashbag.v1"}

take keeps a ranking a ranking, in the retrieval's order. Any other order is the program's own: sorting a ranking by another key makes it a plain sequence of hits.

Retrieval, then the graph

A ranking is a collection of cards, so every step of the earlier chapters applies. expand grows a graph from the best hits, and the nodes it adds say they were reached, not retrieved:

import lexical
cards
| lexical("a creature and its maker")
| take(2)
| expand(depth = 1)
| text
club  (expanded from frankenstein at depth 1)
  -> dune
  -> frankenstein
  -> leaves-of-grass
dune  (expanded from frankenstein at depth 1)
frankenstein  (retrieved 0.2064)
  -> mary-shelley
  -> dune
leaves-of-grass  (expanded from walt-whitman at depth 1)
  -> walt-whitman
mary-shelley  (expanded from frankenstein at depth 1)
  -> frankenstein
walt-whitman  (retrieved 0.1261)
  -> leaves-of-grass

Semantic retrieval

lexical matches spellings: a card that never uses the query's words scores nothing, and a card that uses common ones scores something. semantic scores by meaning, against embeddings a language model computed ahead of time into an index the vault names in hql.toml:

[semantics]
index = ".hql/index.sqlite"

Asked for a creature and its maker, semantic ranks Frankenstein, the relation card that names it, and Mary Shelley, though none of the three contains the word "creature":

import semantic
cards | semantic("a creature and its maker") | take(3) | map(h => h.value.title)
["Frankenstein", "Frankenstein inspires Dune", "Mary Shelley"]

The same question, by spelling, finds Frankenstein and then two cards that share only its small words:

import lexical
cards | lexical("a creature and its maker") | take(3) | map(h => h.value.title)
["Frankenstein", "Walt Whitman", "Librarian"]

A semantic ranking reports the model that embedded the cards, so two indexes built by two models are never compared by score:

import semantic
ranked = cards | semantic("a creature and its maker")
{model: ranked.retrieval.model, metric: ranked.retrieval.metric, approximate: ranked.retrieval.approximate}
{approximate: false, metric: "cosine", model: "sentence-transformers/all-MiniLM-L6-v2"}

The binary never loads a model: contrib/semantics/ embeds the cards and builds the index, and embeds each question the index will be asked, named at build time with --query. The tutorial vault's index holds the three questions this book asks. A question it does not hold is a diagnostic naming the build command, not a ranking of nothing:

import semantic
cards | semantic("night hunting birds") | take(1)
execute failure[invalid-argument]: the index holds no vector for `night hunting birds`, and this binary has no model to make one: rerun `hql-semantics build --query "night hunting birds"`
 --> <expression>:2:9
  |
2 | cards | semantic("night hunting birds") | take(1)
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

hql doctor checks the chain — vault, configuration, index, model, query vectors — and names the first part that is missing.

What is next

A ranking, a graph and a reading list are values a program prints. Chapter 19 writes them into the document that asks for them, and builds the document from its queries.