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') 

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 

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 

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.

▸ get_challenge_details id 82, Hacktoberfest Open-Source AI Challenge: Week 4
id 82, Hacktoberfest Open-Source AI Challenge: Week 4
Theme: Ghost in the Machine. Required tags include devchallenge and hf26challenge. Best Use of Tiger Data covers pgvector embeddings and hybrid keyword plus vector search. Best Use of Gemma covers running Gemma locally. Submissions close October 31, 2026 at 11:59 PM PDT.
▸ ollama_models list local models
list local models
embeddinggemma and gemma4:e4b are already installed. An embedding probe returned 768 dimensions.
▸ tiger_extensions list extensions on the dev service
list extensions on the dev service
timescaledb 2.30.2, vector 0.8.7, and pg_textsearch are available. Host, password, and connection string were removed.
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.

▸ apply_schema create beloved, memories, candle_lights, and candle_lights_daily
create beloved, memories, candle_lights, and candle_lights_daily
Schema applied. Extensions, both indexes, the hypertable, and the continuous aggregate were created.
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.

▸ seance What song did Abuela Rosa love?
What song did Abuela Rosa love?
The answer named La Llorona and the kitchen. That memory ranked first for both keyword search and meaning search.
▸ seance What was June Park's last commit message?
What was June Park's last commit message?
The answer quoted: the tests know more than I do tonight. That memory ranked first on both arms.
▸ browser open the altar and ask the song question
open the altar and ask the song question
The page showed both people, then the La Llorona answer with keyword and meaning ranks under each memory. A later reload showed a newly stored memory and another candle on Rosa's card.

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.