DEV Community

Tharun
Tharun

Posted on

Marigold: an altar that will not invent the dead

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission 👻🌼

This is my entry for the Hacktoberfest Open-Source AI Challenge: Week 4. The theme is Ghost in the Machine: something spooky, or something that honors the past the way a Día de los Muertos ofrenda does, with open-weight models or open-source AI at the center.

I read that as a rule about memory. An ofrenda does not invent a person. It holds what the living place on it: a song, a food, a story, a glass of water. A closed chat model is the opposite. Ask it about someone and it will gladly write a biography. Marigold is a small altar that refuses to do that.

The sample people on the altar are fictional. Abuela Rosa and June Park exist so the page is not empty the first time it opens.

What I built

Marigold is a local web altar. You place a person, then the foods, songs, and stories you want remembered. Two models from the Gemma family run through Ollama on the same machine:

  • embeddinggemma turns each memory into a 768-dimension vector, and turns the question into one too.
  • gemma4:e4b writes the reply.

Tiger Cloud holds the altar. Memories carry a halfvec(768) column and a BM25 index. Candle lights are a Timescale hypertable, with a continuous aggregate of lights per day. There is no retention policy. An ofrenda should not forget.

There is no account. It is a single-user altar on your machine. Judges do not need a password for the page. They do need Ollama and a Tiger connection string, because the open pieces are the product.

The altar, then the answer it gave for Abuela Rosa's song, with the memory that supplied it

Asked "What song did Abuela Rosa love?", Gemma answered that she loved La Llorona, sang it softly in the kitchen, and said the song was about love that refuses to leave. That sentence is in the memory. Asked for June Park's last commit message, it quoted the only line that contains one: "the tests know more than I do tonight."

Under each excerpt the page shows the two ranks that retrieved it. Keyword search and meaning search are not the same list, and the reply is not allowed to leave them.

How a question is answered

flowchart LR
  browser[Browser altar] --> api[FastAPI]
  api --> ollama[Ollama Gemma]
  api --> tiger[Tiger Cloud]
  tiger --> vec[pgvector HNSW]
  tiger --> bm25[pg_textsearch BM25]
  tiger --> candles[candle_lights hypertable]

EmbeddingGemma wants a different prefix for a document than for a query. A memory is stored as title: none | text: .... A question is embedded as task: search result | query: .... Same model, same 768 dimensions, two jobs.

Retrieval is hybrid, on purpose. Pure cosine search is happy to return a paragraph about a song when you asked for a title, or a paragraph about work when you asked for a commit message. Ahmed Mahmoud's field notes on hybrid search describe that failure exactly: embeddings encode meaning, so a rare literal token gets averaged away. Postgres keyword search has the opposite failure. Fusing the two with reciprocal rank, 1 / (60 + rank) on each arm, keeps a literal hit even when its vector rank is poor.

On this Tiger service the keyword arm is pg_textsearch BM25, not tsvector. The <@> operator lives in the extension schema, and it only sees rows when the query is built with the index name:

ORDER BY body OPERATOR(sloth_sort.<@>) sloth_sort.to_bm25query($1, 'memories_body_bm25')
Enter fullscreen mode Exit fullscreen mode

The vector arm is cosine distance on an HNSW index (m = 16, ef_construction = 64), with hnsw.ef_search = 100. If you have selected one person, both queries filter to them, and the vector query sets hnsw.iterative_scan = relaxed_order so the filter does not quietly return fewer rows than you asked for. That early-stop is the other bug Ahmed documents, and it would be a bad one here: a thin result looks like a quiet altar.

Each arm returns up to 20 rows. Reciprocal rank fusion keeps five. Those five excerpts are the only biography Gemma is given. The instruction is short: answer from this list, name the person, and if the list does not contain the answer, say the altar is quiet. If both arms return nothing, the model is not called at all.

Candle lights are the time-series half of Tiger. Each light is an append-only row on a hypertable, segmented by person and ordered by time, with columnstore enabled. A continuous aggregate counts lights per day and stays open for the newest hour, so a candle you just lit still shows up. Memories stay in a normal table. A vector column cannot live in columnstore, so the embeddings and the candles are different shapes of the same database on purpose.

Why open innovation matters here

A remembrance is a poor thing to rent from an API.

The weights that embed a story, and the weights that answer from it, are models I run locally. The question and the five excerpts cross the room to Ollama. They do not cross the public internet to a chat vendor. Rahul Talreja's write-up of a local-first journal makes the split I wanted: the embedding model and the writing model are different jobs, and the writing model should see a handful of retrieved lines rather than the whole archive. His app keeps that archive in SQLite. Mine keeps it in Tiger, because the altar also wants keyword search and a history of candles, and both of those belong next to the vectors.

Running it this way has three practical consequences.

The altar still answers if the network is down, as long as Ollama and the database are up. A closed model does not.

The stories are not sitting in a transcript on someone else's account. The database is mine. The prompt is short enough to read. If a reply invents a date or a feeling, the excerpts are on the page under it, so the invention is visible.

I can swap the chat model without re-embedding, or re-embed without changing how candles are stored. embeddinggemma and gemma4:e4b happen to be the same family. They do not have to be. The dimension check refuses a vector of the wrong length rather than mixing two embedding spaces in one column.

June Park is the other half of the theme, the ghost in the machine. She is fictional, and so is py-marigold, the little library she is remembered for. The point of her card is that a last commit message is a kind of offering too. Keyword search is what stops that message from dissolving into "something about code."

Prize categories

  • Best Use of Tiger Data. Memories are stored as halfvec(768) and searched with HNSW cosine distance. The same rows are searched with BM25 through pg_textsearch. The app fuses the two rankings. Candle lights are a hypertable with a daily continuous aggregate.
  • Best Use of Gemma. embeddinggemma embeds documents and queries with EmbeddingGemma's own prompts. gemma4:e4b writes every reply, locally, through Ollama.

One project, two categories. I am not entering any category whose product this altar does not actually use.

Run it

The repository is github.com/tharun634/marigold-ofrenda, MIT licensed. Python 3.13, Ollama, and a Tiger Cloud dev service are the requirements.

ollama pull gemma4:e4b
ollama pull embeddinggemma
python -m venv .venv
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Copy .env.example to .env. Set DATABASE_URL from tiger db uri YOUR_SERVICE_ID --with-password. Leave that file out of git.

python -m uvicorn backend.app:app --port 8765
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8765. The first launch creates the schema if the altar is empty, then seeds the two sample people. That seed calls Ollama once per memory.

Two buttons on the page ask the questions I used while building: Rosa's song, and June's commit message.

The session

The build session is embedded below. It is a curated transcript: connection strings, passwords, hostnames, tokens, and home-directory paths are not in it. The work happened in Cursor. DEV's uploader has no Cursor value in its tool list, so the session's tool field is the nearest allowed label. The transcript says so, in the first reply.

Building Marigold, a local ofrenda grok-4.7
You

Build a web app for the Hacktoberfest Week 4 DEV challenge, Ghost in the Machine. I have Python, Node, Tiger Cloud, and Ollama locally. Aim for partner prize categories. When it works, publish the DEV article and this agent session after removing sensitive content, put the repo on GitHub, and include a GIF of the app.

Agent

This session was produced in Cursor, with Grok as the model. The uploader has no Cursor value, so the tool field is the closest allowed label and is not a claim that GitHub Copilot wrote the project. Week 4 is live through October 31. The theme is an ofrenda or a ghost in the machine, built around open-weight models. Tiger Data's category is embeddings and hybrid search. Gemma's category is running Gemma locally. I will build Marigold: a single-user altar. Stories are embedded on this machine and stored in Tiger Cloud. The séance may answer only from retrieved memories.

Agent

Memories sit in a normal table with a halfvec(768) HNSW index and a BM25 index, because a vector column cannot live in Timescale columnstore. Candle lights are a hypertable with a daily continuous aggregate and no retention policy. EmbeddingGemma uses its document prompt when storing a memory and its query prompt when asking. Gemma writes the reply from the fused excerpts only.

Agent

The first séance failed because the BM25 operator was not on the default search path, and a bare text comparison could not see the index. Scoring has to go through the extension's query constructor with the index name. After that change, both demo questions were grounded. The sample people are fictional.

This article was drafted and published by an agent. I would rather the altar be strict about memory than fluent about people who were never placed on it.

Top comments (0)