Summary#
You make text findable by marking a field [Searchable] — and that is the whole setup. There is no index to
build, no embedding pipeline to run, and no vector that ever reaches your hands. You opt an app in with
using Osyrin.Memory;, mark the text you want to search, and then search it the way you already query data.
using Osyrin.Memory;
entity Article {
[MaxLength(200)] string Title;
[Searchable] string Body; // that is the entire setup
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
List<Article> Search(string q) {
return Article
.Where(a => a.Body.Matches(q)) // narrow to candidates (index-backed)
.OrderByDescending(a => a.Body.TextScore(q)) // rank them — YOUR formula
.ToList();
}Everything else in this area is two choices layered on top of that: where you search, and which kind of relevance you get.
Description#
1. The one decision — where you search#
There are two search surfaces, and picking between them is the only real decision. You pick per field, with the scope of [Searchable]:
| You want to… | Surface | Scope | How you search |
|---|---|---|---|
| rank one entity's own rows and control the ranking yourself | entity-local field search | [Searchable(Entity)] | field primitives inside an ordinary query (field search (Matches / TextScore / Similarity)) |
| ask one question across everything and let the engine rank | the shared corpus | [Searchable(Memory)] | one call, Memory.Search("…") (using Memory (semantic search)) |
They are not two competing search engines — they are two ergonomics over the same relevance machinery:
- Field search is a query with extra verbs.
Prop.Matches(q),Prop.TextScore(q)andProp.Similarity(q)are values you drop into aWhereand anOrderBy— so you filter, order, threshold and weight them with plain arithmetic, exactly as you would any other query. You reach for it when you want this entity's rows and want to decide what "relevant" means. See field search (Matches / TextScore / Similarity). - The corpus is turnkey.
Memory.Search("how do I get a refund")embeds the query, searches a shared, cross-entity store, fuses lexical and semantic relevance for you, and returns a rankedList<SearchHit>(SearchHit). You reach for it when you want one answer over the whole app and want the ranking done for you. See using Memory (semantic search).
If you write no scope, it is chosen by type — a String defaults to entity-local, a Markdown field defaults to
the corpus (Markdown is usually long and sectioned, so the corpus is its natural home). Write the scope explicitly
to override.
2. The relevance you get is the best available — for free#
Two kinds of relevance exist, and a [Searchable] field gives you both when it can:
- Lexical (full-text) — keyword matching. It needs no external service, so a
[Searchable]field is useful the instant you deploy. - Semantic (vector) — matching by meaning, so
"how do I get a refund"finds a passage about returns and money back with no shared keywords. Semantic ranking is active whenever an embedding provider is configured.
The important part is that this is a degrade, never a fail: deploy with no embedder and your searchable fields
work as full-text and warn that semantic ranking is inactive; wire an embedder later and every Full-mode field
starts ranking by meaning too — with no change to your code. The mode of [Searchable] is where you
opt out of the semantic half (TextOnly) when keyword search is all you want and you would rather not carry the
per-row vector.
3. It is all queries and secured reads#
Nothing here is a new data path. Field search is a query — it obeys the same rules as Querying data, runs in the
database, and sees your uncommitted rows. Memory.Search is an ordinary read: a hit you are not allowed to read
never appears, exactly as a filtered query never returns a row you cannot see. There is no separate "search
permission" to configure and nothing extra to reason about — you declared who may read the entity, and search
returns what a read would.
4. Putting it together#
A field can serve one surface or the other, and an app can use both:
using Osyrin.Memory;
entity Doc {
[MaxLength(200)] string Title;
[Searchable(Memory)] string Summary; // into the shared corpus — turnkey Memory.Search
[Searchable(Entity)] string Notes; // entity-local — rank Doc's own rows yourself
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
// turnkey: one ranked answer across the corpus
List<SearchHit> Ask(string q) {
return Memory.Search(q, limit: 5);
}
// entity-local: this entity's rows, your ranking
List<Doc> ByNotes(string q) {
return Doc.Where(d => d.Notes.Matches(q))
.OrderByDescending(d => d.Notes.TextScore(q))
.ToList();
}See also#
- [Searchable] — the
[Searchable]attribute: scope (entity-local vs corpus) × mode (lexical vs +semantic) - field search (Matches / TextScore / Similarity) —
Matches/TextScore/Similarity: rank an entity's own rows with a ranking you compose - using Memory (semantic search) —
Memory.Search: one turnkey call over the shared corpus - SearchHit — the
SearchHitresult typeMemory.Searchreturns - Memory.Link / Memory.Unlink —
Memory.Link/Memory.Unlink: state why two records are related, in words search can find - Memory.Remember / Memory.Forget —
Memory.Remember/Memory.Forget: a stored file's text, in and out of the corpus - How retrieval works — what happens between the call and the list: indexing, matching, ranking, returning
- Querying data — the querying model field search is built on