> For the complete documentation index, see [llms.txt](https://docs.telys.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.telys.ai/quickstarts/quickstart-semantic-search.md).

# Semantic search

Build a small, searchable text index end to end: create a collection backed by the on-device `AlgentaMultigramEmbedder`, add documents, run text queries, then narrow the results with an equality filter. Every step runs locally.

{% hint style="info" %}
This assumes Telys is installed and you are signed in (`pipx install telys` then `telys login`). If not, do the [Quickstart: your first collection](/quickstarts/quickstart-first-collection.md) first, or see [Install Telys](/start-here/install.md). The examples need the signed runtime and the native kernel; `telys login` installs both.
{% endhint %}

## The embedder: lexical, on-device

`AlgentaMultigramEmbedder` hashes character unigrams, bigrams, and trigrams and L2-normalizes each block. It is **lexical**: similarity reflects shared surface text — words, spelling, morphology — not learned meaning. It runs on-device with no model download, a fast default for keyword-flavoured search. For synonym or paraphrase matching, bring a dense model instead (see [Lexical vs dense embeddings](/understand-telys/lexical-vs-dense.md)).

Its dimension comes from the native kernel, so read it from the profile rather than hard-coding a number:

```python
from telys.embedding import AlgentaMultigramEmbedder

emb = AlgentaMultigramEmbedder()
print(emb.profile.model_id, emb.profile.dimension, emb.profile.distance)
```

**Expected output:**

```
multigram 384 ip
```

{% stepper %}
{% step %}

#### Build the index

Create a collection, declare `category` as a filter column so you can filter on it later, add documents with `add_texts`, and persist with `save()`.

```python
from telys import Telys, Eq
from telys.embedding import AlgentaMultigramEmbedder

t = Telys("./search-demo")
emb = AlgentaMultigramEmbedder()

col = t.create_collection(
    "faq",
    dim=emb.profile.dimension,
    partition_by="scope",
    embedder=emb,
    filter_columns=("category",),
)

col.add_texts(
    texts=[
        "Reset your password from the account settings page.",
        "Export your data as CSV or JSON from the dashboard.",
        "Upgrade to the Pro plan for priority support.",
        "Delete your account permanently under the Danger Zone.",
    ],
    ids=["faq-1", "faq-2", "faq-3", "faq-4"],
    metadata=[
        {"scope": "faq", "category": "account"},
        {"scope": "faq", "category": "data"},
        {"scope": "faq", "category": "billing"},
        {"scope": "faq", "category": "account"},
    ],
)
col.save()
```

`filter_columns` declares which metadata fields Telys stores as indexed columns. `with_metadata=True` returns exactly those fields (undeclared keys are not stored); you can `where`-filter on the partition key or a declared filter column.
{% endstep %}

{% step %}

#### Run a text query

`search_text` embeds the query with the same embedder and returns the closest ids, their scores, and (with `with_metadata=True`) each hit's payload.

```python
hits = col.search_text(
    "how do I change my password?",
    top_k=3,
    with_metadata=True,
)
print(hits)
```

**Expected output:**

```
{'ids': ['faq-1', 'faq-4', 'faq-2'],
 'scores': [0.62, 0.18, 0.09],
 'metadata': [{'category': 'account'},
              {'category': 'account'},
              {'category': 'data'}]}
```

`faq-1` wins because it shares the most surface text with the query ("password", "your"). The other rows still score above zero on incidental n-gram overlap; that is expected for a lexical embedder.
{% endstep %}

{% step %}

#### Filter with Eq

Pass `where=Eq(column, value)` to restrict the search to rows whose column equals a value. Here it keeps only the `account` FAQs.

```python
hits = col.search_text(
    "how do I close my account?",
    top_k=3,
    where=Eq("category", "account"),
    with_metadata=True,
)
print(hits)
```

**Expected output:**

```
{'ids': ['faq-4', 'faq-1'],
 'scores': [0.57, 0.14],
 'metadata': [{'category': 'account'},
              {'category': 'account'}]}
```

Only the two `account` rows are eligible, so `faq-2` (data) and `faq-3` (billing) are excluded before scoring even though you asked for `top_k=3`.

{% hint style="warning" %}
`Eq` is the only filter class, and `where` accepts a single `Eq(...)` or a single-key dict. There are no `And`/`Or`/`Not`/range/`In` operators. To combine constraints, model them as one indexed field, or filter in application code. See [Filter results](/working-with-data/filter-results.md).
{% endhint %}
{% endstep %}
{% endstepper %}

## Reading the scores

* Scores are the inner product of L2-normalized vectors — cosine similarity. `1.0` is an exact match; higher means more similar.
* `ids`, `scores`, and `metadata` are three parallel lists, already sorted best-first.
* Because the embedder is lexical, a query and a document that mean the same thing but share no characters (pure synonyms) score low. If that is your use case, switch embedders — see [Choosing an embedder](/embeddings/choosing-an-embedder.md).
* Filtering happens before scoring, so a filtered query can return fewer than `top_k` rows when the filter is narrow.

## Next steps

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Agent memory</strong></td><td>Turn this into per-user, per-session recall for an AI agent.</td><td><a href="/pages/PAT4pDE2TcuVbgRbANvN">/pages/PAT4pDE2TcuVbgRbANvN</a></td></tr><tr><td><strong>Search by text</strong></td><td>The full guide to search_text, top_k, and recall targets.</td><td><a href="/pages/ljGcBbwv7A5FMTi0vZWR">/pages/ljGcBbwv7A5FMTi0vZWR</a></td></tr><tr><td><strong>Filter results</strong></td><td>Everything Eq can and cannot do, with worked examples.</td><td><a href="/pages/f1kH8mnLEAKBG7sEkDOZ">/pages/f1kH8mnLEAKBG7sEkDOZ</a></td></tr><tr><td><strong>Lexical vs dense</strong></td><td>When to keep the lexical embedder and when to bring a model.</td><td><a href="/pages/xfD01rBgEwUqnMciY7oH">/pages/xfD01rBgEwUqnMciY7oH</a></td></tr></tbody></table>


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.telys.ai/quickstarts/quickstart-semantic-search.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
