Inspiration
Most of the food grown in Nigeria comes from smallholders working a few hectares at a time, and most of them will never speak to an agronomist. Extension officers exist, but there are nowhere near enough of them to walk every field. So when something goes wrong on a farm — leaves curling, birds dying, a crop rotting in store — the person who has to diagnose it is usually the farmer, standing in the field alone, working from experience and whatever a neighbour reckons.
The knowledge is not missing. The access is. IITA, CGIAR, FAO and the national research institutes have published decades of extension material: how to recognise cassava mosaic disease, when to plant maize against the rains, how to store yam so it does not rot, why you must wait before drinking the milk of a treated goat. It is free, it is careful, and it is sitting in PDFs on institutional websites — written for people with a laptop, a stable connection and the vocabulary to search for the right disease by name.
A farmer who does not yet know what is wrong cannot search for its name. That is the gap this is built for: not producing new agricultural knowledge, but putting what already exists into the hands of the person standing in the field, in language they actually use.
The obvious answer is a chatbot over those documents. But every AI capable of reading them wants an API key, a data plan and mains power — and they fail together. When the harmattan takes the power out or the data credit runs dry, the advisory service goes down at exactly the moment the farmer needs it. An advisory tool that only works on good days is not an advisory tool.
So the requirement came from the farm, not from the hardware: it has to work with no internet, on a machine someone already owns, and it has to be honest enough to say when it does not know.
What it does
Àgbẹ̀ (Yoruba for farmer, pronounced AH-gbeh) answers crop, livestock, weather and market questions for smallholders in southwest Nigeria — nine crops plus poultry, goats and cattle — entirely on-device. Ask it in plain language ("my chickens are dying with twisted necks and greenish diarrhoea") and it retrieves the relevant passage from a vetted corpus, answers from that passage, and shows you which manual and which page it used.
The part I care about most is what it refuses to do. It will not tell you how many millilitres of antibiotic to inject into a goat, because that depends on the product, its concentration and the animal's weight — none of which are in any document I have. It will not quote today's maize price, because no static corpus contains one. Knowing precisely what it cannot answer is what makes the rest of it trustworthy.
How I built it
Everything is downstream of a hard 7 GB memory ceiling — exceeding it is
instant disqualification, so it is expressed in code, enforced at runtime with
--memory=7g, and measured on every benchmark run.
- No PyTorch anywhere at runtime. Torch costs 700 MB–1 GB resident before a
single weight loads — about 14% of the budget spent on a framework rather
than a model. The language model and the embedding model both run through the
same llama.cpp process. The Docker build fails if
torchis importable. - Qwen2.5-1.5B-Instruct (Q4_0, GGUF) for generation, bge-small-en-v1.5 for embeddings.
- Hybrid retrieval over ~40,500 passages from ~995 provenance-tracked documents: dense cosine similarity fused with BM25 by reciprocal rank. Dense search knows "twisted necks" and "twisting of the head and neck" are the same thing; lexical search refuses to overlook that "greenish" appears verbatim in exactly one document. Neither alone found the Newcastle disease page; together they do.
- Exact brute-force search, not FAISS or HNSW. At this scale a query is one matrix–vector product, ~1 ms. An approximate index would trade recall for speed I do not need, in a system whose credibility rests on retrieving the right guidance.
- Safety in code, not in the prompt: nine question-side scope rules and thirteen answer-side guards. Any number the model emits that is not present in the cited sources is redacted mid-stream.
Peak memory: 1.63 GB against the 7 GB ceiling. Offline operation is proven,
not asserted — make verify-offline runs the whole thing under --network
none, with no DNS and no route anywhere.
Challenges I ran into
My own test prompt broke the system, and a 100% benchmark did not notice. Asked " my cassava leaves are yellow and twisted…", it confidently answered that the cause was delayed harvesting due to lack of a ready market. That is textbook cassava mosaic disease, and the advice would cost a farmer the crop.
The model was not at fault, and neither was the corpus — which holds 155 passages on cassava mosaic. Printing the actual retrieved text showed the problem: the prompt's own framing ("smallholder farmer", "practical steps", "cultural and preventive measures") is the vocabulary of development-agency reporting, so it retrieved development-agency reports. The four words carrying the diagnosis were outvoted by the scaffolding around them.
The fix was to notice that a prompt contains two different things — a description of a situation, and instructions to the answerer — and that only the description belongs in the search query.
The uncomfortable part: my held-out benchmark scored 100% before that bug and 100% after it. It never saw the failure, because every evaluation question I had written was short and direct, while the prompt a judge writes is long and framed. A passing metric is evidence about the metric before it is evidence about the system.
Other things that cost me days: a Windows path-mangling bug that silently
redirected every Docker bind mount and threw away a 2 GB download; using
--cpus instead of --cpuset-cpus, which made throughput look 30× worse than
reality; and a corpus extractor that returned zero characters from 2,159 pages
because it matched a CSS class the site had changed.
Accomplishments that we're proud of
It fits in 23% of the budget. 1.63 GB peak against a 7 GB ceiling, on a machine with no GPU. Not by shipping something trivial — a 1.5B model, a 384- dimension embedder and ~40,000 indexed passages all live inside that number, because the framework tax was engineered out rather than paid.
Offline is proven, not asserted. make verify-offline runs the whole
system inside a container with --network none — no DNS, no route, no
loopback to the host. If any part of it quietly needed the internet, it fails
there, visibly. "Works offline" is a claim; a container with no network device
answering a farmer's question is evidence.
It knows what it cannot answer — 100% refusal accuracy on the held-out split. It declines to invent a pesticide dose, quote today's market price, or diagnose a human symptom, and it explains why each time. That was harder than making it answer, and it is the reason the answers are worth anything.
I killed my own work when the measurement said to. The NLLB translation bridge was converted, wired and tested before I rejected it for buying zero improvement at ~740 MB. The 3B model was benchmarked before I shipped the 1.5B. Both negative results are written up in full rather than quietly deleted, because a rejected approach with numbers attached is more useful to the next person than a feature list.
Accuracy measured on all 2,376 ARC-Easy questions, not a convenient sample. 50-question runs on this exact model scattered between 0.66 and 0.74 — the full set gives 0.7449. A small sample would have been fast, quotable, and off by up to eight points in either direction.
918 tests, including suites that exist purely because a specific bug came back twice: word-boundary regressions, guards that pass on a whole string and fail on a streamed token, and a test that fails the day someone adds an unvalidated language.
What I learned
Measure the thing, then believe the measurement — even when it is inconvenient.
- I built the NLLB-200 translation bridge to support Nigerian Pidgin, converted it, wired it, and rejected it: it improved Pidgin retrieval from 1/5 questions to 1/5, for ~740 MB of memory, and it mistranslated "my fowl dem dey die, dem neck dey twist" into "my fowl will die, my neck will twist" — turning a poultry question into a human medical one. Nigerian Pidgin is an English-lexified creole, so the translator often copies it through untouched. A 60-line lookup table over Pidgin's grammatical markers took retrieval from 1/5 to 4/5 at zero memory cost.
- Bigger is not better, and I have the bracket to prove it. The 3B model scored identical accuracy at half the speed. The 0.5B failed qualitatively: handed the same correct passages, it diagnosed "a lack of chlorophyll" and advised replanting cuttings from infected plants — precisely how the virus spreads. 1.5B is the measured minimum, bounded on both sides.
- Quantisation format mattered more than parameter count. The audit toolchain builds llama.cpp with all SIMD disabled. On that scalar build, Q4_K_M generates 11.32 tok/s while Q4_0 — the same model, a slightly smaller file — generates 29.5. Reasoning about file size would have picked wrong.
- "The instrument saw nothing" is not "nothing happened." I reported no thermal risk because the container exposes no temperature sensor. Measuring from the Windows host instead showed the CPU running at 95.8 °C under sustained load. It never throttled (throughput actually rose 1.9% over 20 minutes) — but I had generalised a container's blindness into a claim about the machine.
What's next for Àgbẹ̀
The honest list is the one already written down as limitations, not a wishlist:
- Benchmarks on real reference hardware. Peak memory transfers between machines; throughput does not, because generation is memory-bandwidth-bound and bandwidth cannot be throttled in a container. Every throughput figure I publish therefore carries that caveat, and the only way to remove it is to run on the target machine.
- An evaluation set that includes long, framed questions. All 70 of my questions are short and direct. That blind spot is what let a retrieval bug sit behind a 100% score, and I have fixed the bug but not the blind spot.
- Validated Yoruba and Igbo safety messages. Both languages are already detected and the review sheets are generated, but until a native speaker has checked the strings they are answered in English — a machine-translated pesticide warning that reads as a suggestion has failed silently, which is exactly what I measured NLLB doing.
- Answer-level contradiction checking on every question, not just the one it is currently wired for. Every other question could hide the same defect and I would not currently know.
Built With
- bge-embeddings
- bm25
- docker
- fastapi
- gguf
- javascript
- llama-cpp-python
- llama.cpp
- numpy
- pydantic
- pytest
- python
- qwen2.5
- rag
- server-sent-events
- ubuntu
- uvicorn
Log in or sign up for Devpost to join the conversation.