Apropos of nothing, JasperFx very firmly recommends that developers building tools for other developers try to document new technical capabilities early because that can often flush out missing features, unintentional feature gaps, usability problems, and inconsistencies in your new feature set. Ask us how we know that...
Over the last week we shipped full-text search, vector similarity search, and hybrid search across all three of the Critter Stack's document stores: Polecat on SQL Server 2025, Fisher on SQLite, and Marten on PostgreSQL (Marten already had most of this, but we made some enhancements there too).
I'm sure you'll be shocked to find out that JasperFx Software is currently very actively trying to improve our features for AI-assisted software development. Part of that is a new tool in development named "Stoat" that, among other things, will include an option for durable agent memory. Specifically to support Stoat, we pulled full-text and vector search into a shared API supported by all three persistence tools.
We'll be talking much more about Stoat soon, and at the end of this post we'll look at how Stoat already uses these features.
Stoats are a ridiculously cute member of the Mustelidae family.
The short version of the capabilities:
- Full-text search finds documents by the words in them, ranked by relevance.
- Vector search finds documents whose embedding is closest to the embedding of your query, so "why is my projection falling behind" can find a note titled "async daemon lag" that shares no words with it.
- Hybrid search runs both and fuses the two ranked lists into one with reciprocal rank fusion. Text search is great at exact identifiers and rare terms, and vector search is great at meaning. Hybrid search is usually better than either one alone, and it's what you want for recall over something like agent memory.
- Vector projections keep embeddings up to date from your event stream, so you never write the "re-embed this document when it changes" plumbing yourself.
You'll want at least Polecat 5.30.0, Fisher 1.11.0, or Marten and Marten.PgVector 9.37.0, all of which sit on JasperFx.Events 2.72.0.
The shared surface
The shared API definition lives in the JasperFx.Events.Vectors namespace, in the JasperFx.Events package every store already depends on:
- Embeddings.
IEmbeddingProviderhas aDimensionsproperty and aGenerateEmbeddingsAsync(string[])method, plus aGenerateEmbeddingAsync(string)extension for a single piece of text. - Distances.
DistanceFunctionisCosine,L2, orInnerProduct, and it's always a distance: smaller is closer, in every store, for every metric. Scored vector searches returnVectorMatch<T>, a(T Document, double Distance)record. - Hybrid search.
HybridSearchOptions,HybridMatch<T>(larger score is better), andHybridTextStyle(PlainTextorWebStyle) are one set of types for all three stores. - Filters. Every vector and hybrid search takes an optional LINQ
filter, applied before the limit. You get the top 10 documents that match the filter, not whatever survives of the top 10 after filtering. - Store-agnostic search.
IDocumentReadOperations.Searchgives you vector and hybrid search without naming a store at all. - Fusion.
ReciprocalRankFusion.Fuseis public, and it fuses by key, so the lists you fuse don't even have to be the same document type. - Vector projections.
VectorProjectionMap<TId>is the one way to say which events carry the text to embed, including the newMapFromAggregatefor building that text from aggregate state.
Every store is also held to the same compliance suite for search, which checks, among other things, that tenancy, soft deletes, and document hierarchies filter the same way in all three. The shared types, and the reasoning behind each default, are documented in Similarity Search.
If you're already on Microsoft.Extensions.AI, the optional JasperFx.Events.MicrosoftExtensionsAI package adapts any embedding generator to IEmbeddingProvider, so the provider (OpenAI, Azure, Ollama, something running on your laptop) stays your choice:
// Any Microsoft.Extensions.AI IEmbeddingGenerator<string, Embedding<float>>
IEmbeddingProvider embeddings = generator.AsEmbeddingProvider(dimensions: 1536);
var query = await embeddings.GenerateEmbeddingAsync("why is my projection falling behind?");Here's what the shared surface buys you. This recall service compiles once and runs unchanged against a Marten, Polecat, or Fisher session:
using JasperFx.Events.Documents;
using JasperFx.Events.Vectors;
public class PassageRecall(IDocumentReadOperations session, IEmbeddingProvider embeddings)
{
public async Task<IReadOnlyList<HybridMatch<Passage>>> RecallAsync(
string text, string category, CancellationToken token)
{
var query = await embeddings.GenerateEmbeddingAsync(text, token);
return await session.Search.HybridSearchWithScoresAsync<Passage>(
x => x.Embedding, text, query, limit: 10,
filter: x => x.Category == category && !x.Archived,
token: token);
}
}All of the samples below use a document shaped more or less like this:
public class Passage
{
public Guid Id { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public string Category { get; set; } = "";
public bool Archived { get; set; }
// The embedding lives on the document, and is serialized with the rest of it
public float[]? Embedding { get; set; }
}Polecat (SQL Server 2025)
Everything below is in Polecat 5.30.0. Vector search arrived in 5.28, full-text and hybrid search in 5.29, and 5.30 brought vector projections, filters, the extra full-text operators, and the move to the shared surface.
Full-text search: Polecat's own index
The first thing to know about Polecat's full-text search is that it does not use SQL Server's full-text engine. We tried. That engine isn't installed in the mssql/server:2025-latest container, it can't index a computed column over a JSON document body, and it populates its catalog asynchronously, which means a document you just committed might not be found by the very next query. That last one would be a non-starter for agent memory, where "the thing I recorded thirty seconds ago" is exactly what you're looking for.
So Polecat owns its index instead. Declaring a full-text index creates a token table beside the document table, kept up to date by a trigger, and backfilled from any rows you already have:
opts.Schema.For<Passage>().FullTextIndex(x => x.Title, x => x.Body);A write is searchable the moment it commits, and as a bonus, full-text search works on Azure SQL Edge. There are four LINQ operators:
// Every term, in any order
.Where(x => x.Body.PlainTextSearch("projection rebuild"))
// The terms adjacent and in order, using the token positions Polecat stores
.Where(x => x.Body.PhraseSearch("async daemon"))
// A quoted phrase, required words, and an exclusion
.Where(x => x.Body.WebStyleSearch("\"quick brown\" fox -turtle"))
// Search as you type: each word matches the start of a term
.Where(x => x.Body.PrefixSearch("proj reb"))Or ranked by BM25, with a filter, and with the BM25 parameters tuned per call if the defaults don't suit your corpus:
var scored = await session.FullTextSearchWithScoresAsync<Passage>(
x => x.Body, "projection rebuild",
options: new FullTextSearchOptions(K1: 1.5, B: 0.5),
limit: 10,
filter: x => !x.Archived);
foreach (var match in scored)
{
// Larger is more relevant, and scores only compare within one result set
Console.WriteLine($"{match.Score:F2} {match.Document.Title}");
}Do note that Polecat's tokenizer doesn't stem yet. Text is lowercased and split on whitespace and punctuation, so running does not match run. Opt-in stemming is tracked as polecat#642. See Full Text Search, including why the index is Polecat's own and tuning BM25.
Vector search: SQL Server 2025's VECTOR type
opts.Schema.For<Passage>().VectorIndex(x => x.Embedding, dimensions: 1536);Declaring a vector index adds a persisted computed column that casts the embedding out of the JSON document into SQL Server 2025's native VECTOR(n) type. Because the column is computed, Polecat's write path doesn't change at all and existing rows need no backfill.
var nearest = await session.VectorSearchAsync<Passage>(
x => x.Embedding, query, limit: 5,
filter: x => x.Category == "handbook" && !x.Archived);
// Or with the distance, and optionally overriding the metric the index declared
var scored = await session.VectorSearchWithScoresAsync<Passage>(
x => x.Embedding, query, limit: 5, distance: DistanceFunction.L2);Polecat also lets you order by vector distance inside an ordinary LINQ query, so it composes with everything else Query<T>() can do:
var handbook = await session.Query<Passage>()
.Where(x => x.Category == "handbook")
.OrderByVectorDistance(x => x.Embedding, query)
.Take(5)
.ToListAsync();Two things to know. This needs SQL Server 2025, because Azure SQL Edge has no VECTOR type, and Polecat will tell you exactly that (naming the member) if you apply the schema somewhere without one. And every search is an exact scan. SQL Server 2025's approximate vector index is still a preview feature that makes the table read-only, so Polecat doesn't use it. The upside of an exact scan is that a filtered search is exactly the top k of the filtered set, with no recall caveat. See Vector Search, especially filtering and how it works.
Hybrid search
Declare both indexes on the same document, and you can fuse the two:
opts.Schema.For<Passage>()
.FullTextIndex(x => x.Body)
.VectorIndex(x => x.Embedding, dimensions: 1536);var fused = await session.HybridSearchWithScoresAsync<Passage>(
x => x.Embedding, "projection rebuild", query, limit: 10,
options: new HybridSearchOptions(K: 60, CandidateDepth: 100),
filter: x => !x.Archived);Under the covers that's BM25 full-text search and vector search, each fetching a deeper candidate list (max(limit × 4, 50) by default), fused with reciprocal rank fusion. Every document gets Σ 1/(k + rank) over the lists it appears in. Only the ranks are fused, never the raw scores, which is the whole trick: a BM25 score and a cosine distance aren't on any common scale, and RRF doesn't need them to be. The filter applies to both legs, before each leg's candidate depth.
TextStyle: HybridTextStyle.WebStyle swaps the text leg to WebStyleSearch, which comes back unranked, so the fusion has less to go on. If a document declares full-text search over more than one member, use the HybridSearchWithScoresAsync overload that names the text member explicitly. ColumnWeights is refused on Polecat; weight the text by picking the member instead. See Hybrid Search, especially how the fusion works.
Vector projections
New in 5.30. A VectorProjection watches your events and maintains an embedding document:
public class PageVector : IVectorized<string>
{
public string Id { get; set; } = "";
public string? Content { get; set; }
public string? ContentHash { get; set; }
public float[]? Embedding { get; set; }
}
public class PageVectorProjection(IEmbeddingProvider provider)
: VectorProjection<PageVector, string>(provider)
{
protected override void Configure(VectorProjectionMap<string> map)
{
map.Map<PageWritten>(e => e.Data.Text, e => e.Data.PageId);
map.Delete<PageRemoved>(e => e.Data.PageId);
}
}opts.Schema.For<PageVector>().VectorIndex(x => x.Embedding, 1536);
opts.Projections.Add(new PageVectorProjection(embeddings), ProjectionLifecycle.Async);It writes an ordinary document through the session, so tenancy and soft deletes apply to it like anything else, and the embedding commits in the same transaction as the projection's progress. Registering it as anything but Async is refused when the store is built, because an embedding call is a network round trip you don't want inside somebody's SaveChangesAsync. The content hashing, batching, and MapFromAggregate are the shared behavior described in the Fisher section below. See Vector Projections.
Upgrading from 5.29
Three source-breaking changes, all small. HybridTextStyle.Plain is now PlainText, HybridTextStyle.Phrase is gone (use PhraseSearch in a LINQ query), and the vector, hybrid, and ranked full-text methods gained a filter parameter before the cancellation token, so a token passed by position needs a name. Separately, the conjoined-tenancy full-text bug (polecat#625) is fixed, and existing databases repair themselves on upgrade. See the migration guide.
Fisher (SQLite)
Fisher is the newest member of the family: a document and event store in a single SQLite file. It fits local-first tools, embedded apps, and anything that shouldn't need a database server. The search features have been arriving over the past week, and Fisher 1.11.0 brings them onto the shared surface.
Full-text search: FTS5, with relevance, snippets, and highlights
Fisher builds on SQLite's FTS5 extension, which ships in the SQLite bundle Fisher already uses, so there's nothing native to load. Declaring the index creates an FTS5 table kept up to date by triggers:
opts.Schema.For<Passage>().FullTextIndex(x => x.Title, x => x.Body);Unlike Polecat, Fisher gives you a choice of tokenizer. Porter stemming is the default, and Unicode and Trigram are also available. The query operators will look very familiar to Marten users: Search, PlainTextSearch, PhraseSearch, WebStyleSearch, PrefixSearch, and NgramSearch. What FTS5 adds is BM25 relevance ordering with per-column weights, and snippets and highlights right in your Select:
var hits = await session.Query<Passage>()
.Where(x => x.PlainTextSearch("corrosion"))
// Weights follow the order the members were declared: a Title hit counts as much as ten Body hits
.OrderByRelevance(10.0, 1.0)
.Select(x => new { x.Id, x.Title, Extract = x.Snippet() })
.ToListAsync();
// Extract: "…the <b>corrosion</b> on the lower hull was…"See Full Text Search, particularly relevance ordering and snippets and highlights.
Vector search: deliberately brute force
opts.Schema.For<Passage>().VectorIndex(x => x.Embedding, dimensions: 768);
// or, on the member itself
[VectorIndex(768, Distance = DistanceFunction.Cosine)]
public float[]? Embedding { get; set; }The query side is identical to Polecat's, filter included:
var nearest = await session.VectorSearchAsync<Passage>(
x => x.Embedding, query, limit: 5,
filter: x => x.Category == "handbook" && !x.Archived);What's different is underneath. The obvious way to do vector search in SQLite is the sqlite-vec extension, and we passed on it. It's pre-1.0, it isn't on NuGet, and it means a native binary for every platform you deploy to. Instead, Fisher registers its own distance function on each connection. A search is a single SQL statement that computes the distance for every row, orders by it, and takes the limit, with no side table and no approximate index. That sounds naive until you remember who Fisher is for. At thousands of documents it's milliseconds, and at tens of thousands it's still under a second. That's a perfectly good trade for not shipping native binaries.
As of 1.11, that statement is built on the same query Query<T>() uses, so conjoined tenancy, document hierarchies, and soft deletes all apply, and your filter is exact. If you were running a conjoined, multi-tenanted store on 1.10 or earlier, this one is a correctness fix worth upgrading for (fisher#285). See Vector Search, filtering, and why there's no side table.
Hybrid search, with column weights
Same shape, same fusion, and the same defaults as Polecat:
opts.Schema.For<Passage>()
.FullTextIndex(x => x.Title, x => x.Body)
.VectorIndex(x => x.Embedding, dimensions: 768);Fisher is the one store that honors ColumnWeights, which it hands straight to FTS5's bm25() for the text leg. Since reciprocal rank fusion reads the text leg's order, weighting a title hit above a body hit decides which documents make the candidate cut and how they fuse:
var fused = await session.HybridSearchWithScoresAsync<Passage>(
x => x.Embedding, "daemon -rebuild", query, limit: 10,
options: new HybridSearchOptions(TextStyle: HybridTextStyle.WebStyle, ColumnWeights: [3.0, 1.0]),
filter: x => !x.Archived);Raw FTS5 query syntax is deliberately not accepted in the text leg, and a document type that declares only one of the two indexes is refused, rather than quietly running as a one-legged "hybrid" search. See Hybrid Search and weighting the text leg's columns.
Vector projections: embeddings from an event stream
This is where the event store comes in. A VectorProjection watches your events and maintains an embedding document for you:
public class ArticleEmbedding : IVectorized<string>
{
public string Id { get; set; } = "";
public string? Content { get; set; }
public string? ContentHash { get; set; }
public float[]? Embedding { get; set; }
}
public class ArticleVectors(IEmbeddingProvider provider)
: VectorProjection<ArticleEmbedding, string>(provider)
{
protected override void Configure(VectorProjectionMap<string> map)
{
map.Map<ArticleDrafted>(e => e.Data.Body, e => e.Data.Slug);
map.Delete<ArticleWithdrawn>(e => e.Data.Slug);
}
}opts.Schema.For<ArticleEmbedding>().VectorIndex(x => x.Embedding, dimensions: 768);
opts.Projections.Add(new ArticleVectors(embeddings), ProjectionLifecycle.Async);The behavior worth knowing is shared by all three stores:
- The projection stores a SHA-256 hash of the content, so an event that doesn't actually change the text doesn't cost you an embedding call.
- Each page of events gets one call to the provider, not one per event.
- The embedding commits in the same transaction as the projection's progress.
And in Fisher, as in Polecat, it's async only, and anything else is refused when the store is built. An inline projection would hold SQLite's single write lock for the length of a network round trip to your embedding provider, and that is not a thing you want to do to a SQLite file.
The part I want to call out is MapFromAggregate. Mapping one event to one piece of text works until your events are partial updates. A "revised" event that only carries the fields that changed can't produce the whole text to embed. MapFromAggregate builds the text from the aggregate's current state instead, and names the events that should trigger a re-embed:
protected override void Configure(VectorProjectionMap<string> map)
=> map.MapFromAggregate<Article>(
article => $"{article.Title}\n{article.Body}\n{string.Join(", ", article.Tags)}",
(typeof(ArticleDrafted), e => e.StreamKey!),
(typeof(ArticleRevised), e => e.StreamKey!),
(typeof(ArticleTagged), e => e.StreamKey!));If you're upgrading from Fisher 1.10, Configure now takes the shared VectorProjectionMap<TId> rather than Fisher's own two-parameter map, and the hybrid types need using JasperFx.Events.Vectors;. See Vector Projections and building the text from aggregate state.
Marten (PostgreSQL)
As a painful aside and some egg on our face, as AI tools got more effective earlier this year, people using them to hunt for vulnerabilities turned up some long-lingering SQL injection weaknesses in our internals for full-text search. We have made an effort to go over the code internals with a fine-toothed comb and eliminate those vulnerabilities after they came to our attention.
Marten has had PostgreSQL full-text search for years, and the Marten.PgVector package brought vector search and vector projections. Marten and Marten.PgVector 9.37.0 move all of it onto the shared surface, and along the way the searches got a lot more careful about what they return.
Full-text search, briefly
If you haven't looked at Marten's full-text search in a while, it's grown quite a bit. There are the Search, PlainTextSearch, PhraseSearch, WebStyleSearch, and PrefixSearch operators, plus NgramSearch for partial matches, and weighted indexes with relevance ordering:
opts.Schema.For<Achievement>().WeightedFullTextIndex(idx => idx
.Weighted(a => a.Title, TextSearchWeight.A)
.Weighted(a => a.Tagline, TextSearchWeight.B));var ranked = await session.Query<Achievement>()
.Where(a => a.WebStyleSearch(term))
.OrderByTextRank(term, TextSearchFunction.WebStyle)
.ToListAsync();One new safety net in 9.37: if a document has full-text indexes but none for the text search configuration a query asks for, Marten falls back to computing the text vector over the whole document at query time. That works, but it's a sequential scan, so Marten now logs a warning the first time it happens. See Full Text Searching, weighted indexes and relevance ranking, and when no index matches.
Vector search, with an HNSW index that returns what you asked for
opts.UsePgVector();
// An HNSW index, tracked by Marten's schema migrations like any other index
opts.VectorIndex<Passage>(x => x.Embedding, dimensions: 1536, m: 16, efConstruction: 64);UsePgVector() makes sure the vector extension exists in every database Marten manages, tenant databases included. The server still needs pgvector installed, and the pgvector/pgvector:pg17 image is the easy way to get it locally. From there, it's the same calls again:
var scored = await session.VectorSearchWithScoresAsync<Passage>(
x => x.Embedding, query, limit: 5, distance: DistanceFunction.L2);
// Filtered, and reachable from store-agnostic code
var filtered = await session.Search.VectorSearchWithScoresAsync<Passage>(
x => x.Embedding, query, limit: 5, filter: x => x.Category == "handbook");An HNSW scan is approximate, and pgvector caps how many candidates it considers with the hnsw.ef_search setting, which defaults to 40. Left alone, that cap means an indexed search asking for 100 rows gets 40 back. Marten now sizes it for you: an indexed search sets hnsw.ef_search to the rows it needs (at least 40, at most pgvector's limit of 1000), and on pgvector 0.8 or later also turns on hnsw.iterative_scan, so a filtered search still returns its limit. Both settings are scoped to that one statement. The remaining boundaries are that an indexed search asking for more than 1000 rows gets 1000, and on pgvector older than 0.8 a very selective filter can still come back short. See recall, and what Marten sets for you.
One gotcha is still pgvector's: an HNSW index serves exactly one distance metric. Declare the index for cosine and search by L2, and PostgreSQL quietly scans the whole table instead. The session.Search accessor and hybrid search now default to the metric your index declared, which removes most of the opportunity to get this wrong.
9.37 also tightened what a search returns, so a vector or hybrid search now behaves like Query<T>() over the same documents:
- Soft-deleted documents are excluded. They used to come back, which is a behavior change worth knowing about if you were filtering them out yourself.
- A search over a document hierarchy's base type returns each match as its concrete subtype, and a search for a subclass returns only that subclass.
- Conjoined tenancy is keyed on the document type, not on the store.
- Searches run on the session's own connection and transaction, so they see the session's uncommitted writes.
See what a search filters for you.
Hybrid search
Marten's hybrid search fuses ts_rank full-text search with the vector search above:
opts.UsePgVector();
opts.Schema.For<Passage>().FullTextIndex();var fused = await session.HybridSearchWithScoresAsync<Passage>(
x => x.Embedding, "projection rebuild -snapshot", query, limit: 10,
options: new HybridSearchOptions(TextStyle: HybridTextStyle.WebStyle, RegConfig: "english"));RegConfig, the PostgreSQL text search configuration, is the one option only Postgres honors. It also decides which full-text index serves the text leg: Marten uses the index registered for that configuration, weighted indexes included. Two full-text indexes on the same configuration throw an AmbiguousFullTextIndexException rather than guessing. ColumnWeights is refused on Marten, because weighting happens at index time with WeightedFullTextIndex. As with vector search, the filter lives on session.Search.HybridSearchWithScoresAsync. See hybrid search and the text leg.
Vector projections
Marten's VectorProjection<TId> writes to a dedicated table rather than to a document, and it now declares its events with the same shared map:
// Fully qualified: Marten.PgVector.Projection still has its obsolete IEmbeddingProvider, so a file
// importing both namespaces gets CS0104 on the bare name
public class ProductSearchProjection(JasperFx.Events.Vectors.IEmbeddingProvider provider)
: VectorProjection<Guid>("product_search_vectors", provider)
{
protected override void Configure(VectorProjectionMap<Guid> map)
{
map.Map<ProductCreated>(e => $"{e.Data.Name} {e.Data.Description}", e => e.Data.ProductId);
map.Map<ProductUpdated>(e => e.Data.Description, e => e.Data.ProductId);
map.Delete<ProductDeleted>(e => e.Data.ProductId);
}
}var projection = new ProductSearchProjection(embeddings);
opts.UsePgVector();
opts.Projections.Add(projection, ProjectionLifecycle.Async);
opts.Storage.ExtendedSchemaObjects.Add(projection.BuildTable(opts));
// ...and later, each match is (Id, Distance, ContentText)
var matches = await session.VectorProjectionSearchAsync<Guid>(
"product_search_vectors", query, limit: 10);A few things changed here in 9.37:
- Ids aren't
Guid-only anymore.TIdis whatever identifies your documents. BuildTable(opts)is the registration to use. It reads the tenancy off the store, so on a conjoined store the table is keyed by tenant and id, and each tenant only ever sees its own embeddings. A conjoined store that builds the table the old way is refused at startup. If you already have a table on a conjoined store, truncate it and rebuild the projection after upgrading.MapFromAggregateworks here too, as an async-only feature, because it aggregates the stream from committed events. Unlike Polecat and Fisher, Marten doesn't refuse an inline registration yet (marten#5451), so register it asAsyncyourself.- The hash reads and the writes all ride the session, so an embedding commits with the events that produced it.
The existing non-generic VectorProjection and its older mapping API still compile and still work. See event-sourced vector projection and building content from aggregate state.
Upgrading to 9.37
Mostly additive, with three things to read first. The hybrid types moved to JasperFx.Events.Vectors, so 9.36 code needs one using. HybridSearchOptions.Distance now defaults to the index's metric rather than always meaning cosine. And vector and hybrid searches exclude soft-deleted documents. See what changed in 9.37, and upgrading from the pre-9.36 API if you're coming from further back.
Side by side
| Polecat | Fisher | Marten | |
|---|---|---|---|
| Database | SQL Server 2025 | SQLite | PostgreSQL |
| Package(s) | Polecat 5.30.0 | Fisher 1.11.0 | Marten + Marten.PgVector 9.37.0 |
| Full-text engine | Polecat's own token index | SQLite FTS5 | PostgreSQL tsvector |
| Stemming | Not yet | Porter by default | Per text search configuration |
| Relevance ranking | BM25, tunable k1/b | BM25 with column weights | ts_rank, weighted indexes |
| Vector storage | Computed VECTOR(n) column | JSON, distance function in SQL | pgvector |
| Approximate index | No, exact scan | No, exact scan | HNSW, ef_search sized per search |
| Search filters | Yes | Yes | Through session.Search |
| Hybrid column weights | Refused | Yes | Refused, weight the index instead |
| Vector projection | VectorProjection<TDoc, TId> | VectorProjection<TDoc, TId> | VectorProjection<TId>, to a table |
| Projection lifecycle | Async only | Async only | Inline or async |
The embedding provider, metrics, hybrid options, filters, fusion, and projection maps are the same types in all three stores, and session.Search reads the same everywhere. What differs is what each database is genuinely good at, and I'd rather the table above say that plainly than paper over it.
How Stoat uses it
Stoat's agent memory is event sourced on Fisher. Every memory is its own event stream: recorded, revised, refiled, linked, archived, and restored, each event noting who changed it and when. An inline snapshot serves as both the write model and the thing you search. The SQLite file is the whole deployment, which is exactly what you want from a tool that runs next to your agents on a developer's machine.
Recall, the thing an agent calls before it starts work, is FTS5 first:
store.Projections.Snapshot<MemoryRecord>(SnapshotLifecycle.Inline);
store.Schema.For<MemoryRecord>().FullTextIndex(x => x.Title, x => x.Body, x => x.TagsText);// PlainTextSearch is safe to hand raw agent input to, and a prefix search catches partial words
var lexical = await run(x => x.PlainTextSearch(text), depth);
if (lexical.Count == 0 && text.Length >= 2)
lexical = await run(x => x.PrefixSearch(text), depth);
// and inside run(): a title hit outweighs a tag hit, which outweighs a body hit
var ranked = await q.OrderByRelevance([3.0, 1.0, 2.0]).Take(fetch).ToListAsync(ct);The semantic side is optional. Out of the box there's no embedding provider, so Stoat declares no vector index and recall is full-text only, with no errors. Plug in an IEmbeddingProvider, and Stoat adds a vector index and an async projection that maintains memory embeddings, then fuses the two legs with reciprocal rank fusion. A brand new memory can be found by its words immediately, and by its meaning a moment later once the projection catches up.
Honestly, Stoat is where most of the shared surface came from, because it hit every gap first:
- A memory's "revised" event only carries the fields that changed, so the text to embed has to come from the memory's current snapshot. That's exactly the case
MapFromAggregatenow covers. - The full-text index lives on the memory snapshot and the embedding lives on a separate document, and
HybridSearchAsyncfuses two indexes on one document type.ReciprocalRankFusion.Fusenow fuses by key across document types, so nobody has to hand-roll that again. - Recall is scoped, and filtering a vector search after the limit can leave you with nothing. That's why every search now takes a
filterapplied before it.
If your text and your vectors end up on different documents too, over-fetch both legs and hand them to ReciprocalRankFusion.Fuse.
Wrapping up
Stoat will be a licensed commercial tool, but we expect to bundle it with our existing AI Skills product and make it available to any user who has purchased a support plan, the AI Skills, or CritterWatch. Agent memory is only one planned feature, and Stoat will also play a key role in our grand "Event Modeling" strategy that we'll start demonstrating soon.
If you're building retrieval, semantic search, or agent memory on the Critter Stack and you'd like help with the design, JasperFx support plans get you direct access to the people who wrote this code, and our consulting services cover everything from architecture reviews to hands-on delivery. And as always, come talk shop with us on Discord.



