moonrag

    Documents, splitting, vector stores and retrieval for MoonBit. Embeddings come through a contract, so no model is needed.

    rag
    retrieval
    retrieval-augmented-generation
    vector
    vector-store
    vector-search
    vector-database
    embeddings
    embedding-store
    similarity-search
    semantic-search
    cosine-similarity
    euclidean-distance
    mmr
    maximal-marginal-relevance
    retriever
    document
    document-loader
    document-splitting
    text-splitter
    chunking
    chunk
    recursive-splitter
    token-splitter
    html-splitter
    markdown
    csv
    json
    html
    indexing
    incremental-index
    knowledge-base
    context-window
    async
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    12 hours ago
    Downloads
    7

    #moonrag

    Documents, splitting, vectors and retrieval for MoonBit. Embeddings arrive through a contract, so nothing here calls a model.

    CI license


    async fn answer(embedder : &@moonrag.Embedder) -> Array[@moonrag.Scored] raise {
    let store = @moonrag.InMemoryVectorStore::new(embedder)
    let _ = store.add([
    @moonrag.Document::new(
    "the harbour at dawn, fishing boats leaving",
    meta=Map([("topic", @json.Json::string("sea"))]),
    ),
    @moonrag.Document::new("a mountain path in autumn, cold and clear"),
    ])
    let query = embedder.embed(["boats in the harbour"])[0]
    store.search(
    query[:],
    limit=1,
    filter=@moonrag.Filter::Equal("topic", @json.Json::string("sea")),
    )
    }

    Five stages, five contracts: load bytes into documents, split them into chunks, embed the chunks, store the vectors, retrieve the best. Every stage is replaceable without touching the others, and none of them knows which embedding model is in use.

    #Install

    moon add moonbitstack/moonrag

    import {
    "moonbitstack/moonrag", // documents, contracts, memory store, retrieval
    "moonbitstack/moonrag/split", // recursive, token and HTML splitters
    "moonbitstack/moonrag/load", // text, CSV, JSON, Markdown, HTML loaders
    "moonbitstack/moonrag/load/file", // the same loaders, reading from disk
    "moonbitstack/moonrag/index", // incremental indexing
    }

    #A document is the unit

    Everything moves Document: some text, an optional id, and metadata. The id is what a store keys by — set it and a re-index updates in place; leave it empty and an id is derived from the content. The metadata is what a filter reads, and what a retrieved chunk uses to say where it came from.

    let document = @moonrag.Document::new("some text", id="guide.md#2")
    .set_meta("source", @json.Json::string("guide.md"))
    .set_meta("page", @json.Json::number(3.0))

    Splitters add chunk and, when asked, start_index; the HTML splitter adds h1 … h6. Nothing else is reserved.

    #Filters travel with the query

    A Filter is data, so a store applies it where the documents live instead of the caller re-ranking afterwards:

    let recent = @moonrag.Filter::All([
    @moonrag.Filter::Equal("topic", @json.Json::string("sea")),
    @moonrag.Filter::Compare("year", @moonrag.Order::AtLeast, 2025.0),
    ])

    Equal, OneOf, Exists, Compare against Less/AtMost/AtLeast/Greater, combined with All, Any, Not. A document missing the key matches nothing except a Not of a filter it fails.

    #Embedding is a contract

    pub(open) trait Embedder {
    async fn embed(Self, texts : ArrayView[String]) -> Array[Array[Double]] raise
    }

    One call embeds a batch, because that is what a model charges for and what makes a re-index affordable. Implement it over a hosted model, a local one, or a deterministic stand-in: the rest of the library never learns which.

    HashingEmbedder is such a stand-in: it hashes words and character trigrams into a fixed-width vector, needs no network, and returns the same vector for the same text on every target. It is lexical rather than semantic — useful for tests, examples and keyword-ish retrieval, not for production ranking.

    #Storing

    InMemoryVectorStore is the reference implementation of VectorStore: documents in insertion order, every candidate scored on each search, filters applied before ranking.

    let store = @moonrag.InMemoryVectorStore::new(embedder)
    let ids = store.add([document])
    let hits = store.search(query_vector, limit=5)
    store.delete([ids[0]][:])

    An id that is already stored is replaced in place, which is what makes a re-index an update rather than a duplicate. Hits carry the id they are stored under, so a caller can delete what it just retrieved.

    Already have vectors? add_vectors takes them and skips the embedding.

    #Retrieving

    SimilarityRetriever embeds the query and ranks by cosine similarity.

    MmrRetriever reranks by maximal marginal relevance: each round scores every remaining candidate as lambda × similarity to the query − (1 − lambda) × highest similarity to something already picked. Near lambda = 1 it behaves like a plain search, near 0 like "the most different thing that is still related" — which is what stops a corpus of near-duplicates from filling the answer with the same paragraph five times.

    let plain : &@moonrag.Retriever = @moonrag.SimilarityRetriever::new(embedder, store)
    let diverse : &@moonrag.Retriever = @moonrag.MmrRetriever::new(embedder, store, lambda=0.3)

    The MMR function itself is public (mmr) for callers that rerank their own candidates.

    #Splitting

    Recursive tries separators from coarse to fine — paragraph, line, word, character — and merges the pieces back to the chunk size with an overlap. A piece that still does not fit is split again by the next separator, so the result follows natural boundaries for as long as it can.

    let splitter = @split.RecursiveSplitter::new(chunk_size=1000, chunk_overlap=200)
    let chunks = splitter.split(document)

    The defaults (4000/200, separator kept at the start of the following chunk, whitespace trimmed) and the arithmetic follow a reference Python implementation of the same rules, and the fixtures in src/conformance replay it case for case.

    Token measures in the units that will be billed, with a counter you supply:

    let splitter = @split.TokenSplitter::new(count_tokens, chunk_size=512, chunk_overlap=64)

    Each chunk is measured on its assembled text, so the promise holds for the real tokenizer rather than for an estimate.

    HTML reads the parsed tree, one chunk per heading-delimited section, with the heading path in the metadata:

    let splitter = @split.HtmlSplitter::new(chunk_size=1000)
    let sections = splitter.split(@moonrag.Document::new(page_html))
    // sections[1].str_meta("h1") == Some("Install"); .str_meta("h2") == Some("Search")

    A section longer than the budget is cut again by the recursive splitter with the same heading path, so no chunk outgrows an embedding window.

    #Loading

    FormatWhat a document isMetadata
    TextLoaderthe whole textsource
    CsvLoaderone row, written as column: value linessource, row, every column
    JsonLoaderone element of an array, or the objectsource, every field except the text key
    MarkdownLoaderthe whole filesource, title from the first heading
    HtmlLoaderthe readable text of the pagesource, title

    let documents = @moonrag.Loader::load(@load.CsvLoader::new("people.csv", csv_text))

    CSV follows RFC 4180: quoted fields keep delimiters, doubled quotes and newlines. JSON takes a text_key to say which field is prose; without one it uses text, and failing that writes the object out so nothing is lost.

    Reading from disk lives one level down, in load/file, because a filesystem is not something every target has:

    let documents = @file.csv("people.csv")
    let text = @file.markdown("notes.md")

    #Indexing

    Re-running a pipeline should not re-embed a corpus that has not changed. The indexer keeps a content hash per stored id:

    let indexer = @index.Indexer::new(store)
    let stats = indexer.index(chunks) // added / updated / skipped / deleted
    let cleaned = indexer.index(chunks, cleanup=@index.Cleanup::Full)

    • unchanged — same id, same content hash: skipped, nothing embedded.
    • changed — same id, different text or metadata: the old copy is deleted, the new one added.
    • gone — Incremental deletes documents whose source appears in this run and whose id does not; Full deletes everything this run did not produce; None deletes nothing.

    The source key defaults to source, the metadata key every loader writes. Moving a file changes the source but not the hash, so a moved file is not re-embedded.

    #Interface

    Documentid : String?, text : String, meta : Map[String, Json]
    Scoreddocument : Document, score : Double
    FilterEqual, OneOf, Exists, Compare, All, Any, Not
    Embedderasync embed(texts : ArrayView[String]) -> Array[Array[Double]] raise
    VectorStoreasync add(Array[Document]) -> Array[String] raise, async delete(ids) raise, async search(vector, limit~, filter?) -> Array[Scored] raise
    Retrieverasync retrieve(query : String, limit~ : Int32) -> Array[Scored] raise
    Splittersplit(Document) -> Array[Document]
    Loaderasync load() -> Array[Document] raise
    RefusedMismatch, Empty, Exceeded, Malformed, Unsupported

    VectorStore::add takes documents and search takes a vector: writing is the batched, cacheable path, while a search often reuses one query vector across stores.

    #Conformance

    • Splitters. The recursive splitter replays 56 cases and the token splitter 32 — prose, code, CJK, whitespace-heavy and separator-free text, across four sizes, three separator placements and both whitespace settings — produced by a reference Python implementation of the same rules (1.1.3). Chunks match exactly; scripts/gen_split_vectors.py regenerates them.
    • Store and retrieval. The behaviours a vector-store standard suite requires — add, search with scores, limit, metadata filtering, delete by id, update by id, empty store, dimension mismatch, order independence — plus a retriever's limit, descending scores, filters and MMR diversity.
    • End to end. Load, split, index and retrieve as one test over a page and a note, including a second pass that must embed nothing.
    • Against a filesystem. The file front end is tested by writing real files to a temporary directory and reading them back through all five loaders.

    #Examples

    #ExampleWhat it shows
    01ingestload, split, index, retrieve, then index again and embed nothing
    02storeadd, filter, rank, rerank with MMR, delete
    03filesthe file front end over a real temporary directory

    moon run src/examples/01-ingest --target native

    #License

    Apache-2.0.

    Embedder

    pub(open) trait Embedder {
    async fn embed(Self, texts : ArrayView[String]) -> Array[Array[Double]]
    }

    Turns batches of text into vectors of equal length.

    One call embeds a batch, not a string: every model worth calling charges per request, and a batch is also what makes a re-index affordable. All vectors in a batch must have the same length, and the same text must embed to the same vector — ranking is keyed on that.

    Loader

    pub(open) trait Loader {
    async fn load(Self) -> Array[Document]
    }

    Produces documents from somewhere — a file, a directory, an API, a literal string.

    Failure is part of the contract because the source may be unreadable or malformed; a loader that quietly returns nothing looks identical to an empty document and hides the problem.

    Retriever

    pub(open) trait Retriever {
    async fn retrieve(Self, query : String, limit~ : Int) -> Array[Scored]
    }

    Answers a question in text: embeds it, searches, returns the best documents.

    A retriever is the whole read path behind one call; a reranker, a query rewrite, or MMR is an implementation of this trait and nothing above it changes.

    Splitter

    pub(open) trait Splitter {
    fn split(Self, document : Document) -> Array[Document]
    }

    Cuts one document into smaller documents.

    Splitting is not async: it is pure text work with no I/O, and keeping it synchronous lets it run inside a parser or a test without an event loop.

    VectorStore

    pub(open) trait VectorStore {
    async fn add(Self, documents : Array[Document]) -> Array[String]
    async fn delete(Self, ids : ArrayView[String]) -> Unit
    async fn search(Self, vector : ArrayView[Double], limit~ : Int, filter? : Filter) -> Array[Scored]
    }

    Where vectors live.

    add takes documents, not vectors: the store owns the embedder it was built with, so a caller never has to keep the two in step by hand. search takes the query's vector, because the query is often embedded once and searched in several stores, and because an embedding call is the expensive part a caller wants to place itself.

    Refused

    pub(all) suberror Refused {
    Mismatch(got~ : Int, want~ : Int)
    Empty(what~ : String)
    Exceeded(limit~ : Int, got~ : Int)
    Malformed(at~ : Int, why~ : String)
    Unsupported(what~ : String)
    } derive(Eq,
    Debug
    )

    A request the library will not carry out.

    Mismatch is two vectors of different length meeting where one length is needed, Empty a blank string where one has to be non-blank, Exceeded a number outside the range limit bounds, Malformed unreadable input at at, and Unsupported a request this build does not serve.

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::to_repr

    Candidate

    pub(all) struct Candidate {
    document : Document
    vector : Array[Double]
    }

    A document together with the vector it was embedded to.

    MMR reranks by comparing candidates with each other, and a score alone cannot do that: the vectors have to travel with the documents.

    Document

    pub(all) struct Document {
    id : String?
    text : String
    meta : Map[String,
    Json
    ]
    } derive(Eq,
    Debug
    )

    A piece of text, with an optional identity and metadata.

    id is what a store keys a document by. Leave it None and the store assigns one; set it and the document keeps the same identity across re-indexing, which is what makes an update replace the old copy instead of piling up beside it. meta carries whatever the caller needs — a source path, a page number, a heading path — and is what a Filter reads.

    Document::derived

    fn Document::derived(self : Document, index : Int, text : String, start_index? : Int) -> Document

    A document cut out of parent: same metadata plus the chunk number, a derived id when the parent had one, and the offset it started at when the splitter knows it.

    Splitters are not the only producers of chunks — a caller streaming pages out of a PDF has the same needs — so the bookkeeping lives here rather than in each of them.

    Document::equal

    fn Document::equal(Document, Document) -> Bool

    Document::meta_of

    fn Document::meta_of(self : Document, key : String) ->
    Json
    ?

    The metadata value stored under key, or None.

    Document::new

    fn Document::new(text : String, id? : String, meta? : Map[String,
    Json
    ]) -> Document

    A document with text, no identity, and no metadata.

    Document::not_equal

    fn Document::not_equal(x : Document, y : Document) -> Bool

    Document::set_meta

    fn Document::set_meta(self : Document, key : String, value :
    Json
    ) -> Document

    The same document with key set to value; a key already present is replaced.

    Document::str_meta

    fn Document::str_meta(self : Document, key : String) -> String?

    The metadata value stored under key when it is a string, or None.

    Document::to_json

    Document::to_repr

    Document::with_id

    fn Document::with_id(self : Document, id : String) -> Document

    The same document under id, for a store that keys by caller-chosen identity.

    Document::with_meta

    fn Document::with_meta(self : Document, metadata : Map[String,
    Json
    ]) -> Document

    The same document carrying metadata, for a filter to match on.

    Filter

    pub(all) enum Filter {
    Equal(String,
    Json
    )
    OneOf(String, Array[
    Json
    ])
    Exists(String)
    Compare(String, Order, Double)
    All(Array[Filter])
    Any(Array[Filter])
    Not(Filter)
    } derive(Eq,
    Debug
    )

    A condition on a document's metadata.

    Equal matches a whole JSON value, OneOf any of a list, Exists only that the key is present, and Compare a number against an operand. All and Any nest, Not negates. A document whose metadata lacks the key matches nothing except Not of a filter it would otherwise fail.

    Filter::equal

    fn Filter::equal(Filter, Filter) -> Bool

    Filter::matches

    fn Filter::matches(self : Filter, document : Document) -> Bool

    Whether document satisfies the filter.

    Filter::not_equal

    fn Filter::not_equal(x : Filter, y : Filter) -> Bool

    Filter::to_repr

    HashingEmbedder

    pub struct HashingEmbedder {
    dimension : Int
    } derive(
    Debug
    )

    A deterministic embedder that needs neither a model nor a network: it hashes the words and the character trigrams of a text into a fixed-width vector.

    It is lexical, not semantic — "cat" and "feline" land far apart — so it suits tests, offline examples and keyword-ish retrieval, not production ranking. Being deterministic, it is also the vector source for differential tests: the same text yields the same vector on every target.

    HashingEmbedder::embed

    async fn HashingEmbedder::embed(self : HashingEmbedder, texts : ArrayView[String]) -> Array[Array[Double]]

    HashingEmbedder::new

    fn HashingEmbedder::new(dimension? : Int) -> HashingEmbedder raise

    A hashing embedder producing dimension-component vectors. 256 is wide enough that a handful of documents rarely collide and narrow enough to keep a fixture readable.

    Raises Empty when dimension is not positive.

    InMemoryVectorStore

    pub struct InMemoryVectorStore {
    embedder : &Embedder
    entries : Array[(String, Document, Array[Double])]
    counter : Int
    }

    A VectorStore held in memory, keyed by document id.

    The store embeds what it is given, so add takes documents while a search takes the query's vector. That split is deliberate: writing is the rare, batched, cacheable path, while a search often reuses one query vector across several stores.

    InMemoryVectorStore::add

    async fn InMemoryVectorStore::add(self : InMemoryVectorStore, documents : Array[Document]) -> Array[String]

    InMemoryVectorStore::add_vectors

    fn InMemoryVectorStore::add_vectors(self : InMemoryVectorStore, documents : Array[Document], vectors : ArrayView[Array[Double]]) -> Array[String] raise

    Store documents that already carry their vectors, returning their ids. An id already present is replaced in place, which is what makes a re-index an update rather than a duplicate.

    Raises Mismatch when the vector count differs from the document count or two vectors differ in length, and Empty for a document whose id is the empty string.

    InMemoryVectorStore::clear

    fn InMemoryVectorStore::clear(self : InMemoryVectorStore) -> Unit

    Remove every stored document.

    InMemoryVectorStore::delete

    async fn InMemoryVectorStore::delete(self : InMemoryVectorStore, ids : ArrayView[String]) -> Unit

    InMemoryVectorStore::get

    fn InMemoryVectorStore::get(self : InMemoryVectorStore, id : String) -> Document?

    The document stored under id, or None. The returned document carries the id it is stored under, so it can be deleted or refetched without keeping the id beside it.

    InMemoryVectorStore::ids

    fn InMemoryVectorStore::ids(self : InMemoryVectorStore) -> Array[String]

    The ids of the stored documents, in the order they were added.

    InMemoryVectorStore::len

    The number of stored documents.

    InMemoryVectorStore::new

    An empty store that embeds with embedder.

    InMemoryVectorStore::search

    async fn InMemoryVectorStore::search(self : InMemoryVectorStore, vector : ArrayView[Double], limit~ : Int, filter? : Filter) -> Array[Scored]

    InMemoryVectorStore::vector_of

    fn InMemoryVectorStore::vector_of(self : InMemoryVectorStore, id : String) -> Array[Double]?

    The vector stored for id, or None — for a caller that reranks hits itself.

    MmrRetriever

    pub struct MmrRetriever {
    embedder : &Embedder
    store : &VectorStore
    filter : Filter?
    lambda : Double
    fetch_limit : Int
    }

    Returns relevant documents that are also unlike each other, by maximal marginal relevance.

    A plain search on a corpus of near-duplicates returns the same paragraph five times. MMR trades some relevance for coverage: lambda near 1 behaves like a plain search, near 0 like "the most different thing that is still related".

    MmrRetriever::new

    fn MmrRetriever::new(embedder : &Embedder, store : &VectorStore, lambda? : Double, fetch_limit? : Int, filter? : Filter) -> MmrRetriever

    An MMR retriever over store. fetch_limit candidates are ranked by similarity first and then reranked by MMR, so it must be at least the limit a caller asks for; lambda weights relevance against diversity.

    MmrRetriever::retrieve

    async fn MmrRetriever::retrieve(self : MmrRetriever, query : String, limit~ : Int) -> Array[Scored]

    Order

    pub(all) enum Order {
    Less
    AtMost
    AtLeast
    Greater
    } derive(Eq,
    Debug
    )

    How Compare orders a metadata number against its operand.

    Order::equal

    fn Order::equal(Order, Order) -> Bool

    Order::not_equal

    fn Order::not_equal(x : Order, y : Order) -> Bool

    Order::to_repr

    Scored

    pub(all) struct Scored {
    document : Document
    score : Double
    } derive(Eq,
    Debug
    )

    A document and the score it earned: what one search returns.

    Scored::documents

    fn Scored::documents(hits : ArrayView[Scored]) -> Array[Document]

    The documents in hits, dropping the scores.

    Scored::equal

    fn Scored::equal(Scored, Scored) -> Bool

    Scored::not_equal

    fn Scored::not_equal(x : Scored, y : Scored) -> Bool

    Scored::to_json

    Scored::to_repr

    SimilarityRetriever

    pub struct SimilarityRetriever {
    embedder : &Embedder
    store : &VectorStore
    filter : Filter?
    }

    Ranks documents by cosine similarity to the query, and returns the best limit.

    SimilarityRetriever::new

    fn SimilarityRetriever::new(embedder : &Embedder, store : &VectorStore, filter? : Filter) -> SimilarityRetriever

    A similarity retriever over store, embedding queries with embedder. filter narrows the candidates before ranking, in the store where the documents live.

    SimilarityRetriever::retrieve

    async fn SimilarityRetriever::retrieve(self : SimilarityRetriever, query : String, limit~ : Int) -> Array[Scored]

    cosine_similarity

    fn cosine_similarity(left : ArrayView[Double], right : ArrayView[Double]) -> Double raise

    The cosine similarity of two vectors, in [-1, 1]: 1 for the same direction, 0 for orthogonal vectors or when either is all zeros, -1 for opposite directions.

    Raises Mismatch when the lengths differ. Cosine ignores magnitude, so a long document and a short one about the same thing score alike — which is why it is the usual ranking metric for text.

    dot_product

    fn dot_product(left : ArrayView[Double], right : ArrayView[Double]) -> Double raise

    The dot product of two vectors of the same length.

    Raises Mismatch when the lengths differ.

    euclidean_distance

    fn euclidean_distance(left : ArrayView[Double], right : ArrayView[Double]) -> Double raise

    The Euclidean distance between two vectors: 0 for identical vectors, larger for further ones.

    Raises Mismatch when the lengths differ. Use it when the embedding model was trained on it; ranking by distance ascending and by similarity descending agree only for normalised vectors, so the two are kept apart rather than folded into one.

    magnitude

    fn magnitude(vector : ArrayView[Double]) -> Double

    The Euclidean norm of a vector.

    mmr

    fn mmr(query : ArrayView[Double], candidates : ArrayView[Candidate], limit~ : Int, lambda? : Double) -> Array[Scored] raise

    Pick up to limit candidates that are relevant to query and unlike each other.

    Each round scores every remaining candidate as lambda * similarity to the query - (1 - lambda) *highest similarity to an already picked one, and takes the best; lambda = 1 degenerates to plain similarity, lambda = 0 to "show me the most novel document". Ties go to the earlier candidate, so the result is deterministic.

    Raises Mismatch when a candidate vector's length differs from the query's.

    normalized

    fn normalized(vector : ArrayView[Double]) -> Array[Double]

    vector scaled to unit length; an all-zero vector comes back unchanged rather than as NaNs.