Offline RAG agent over a corpus of Colombian news. Locally run, using Ollama.
The agent answers questions strictly from a corpus of Colombian news collected from 92 RSS local sources. The reference build spans ~120,000 articles from November 2025 to June 2026; the corpus isn't redistributed here (see Data), but the pipeline reproduces it and a sample is included.
Ask it something the corpus covers, and it answers with sources and dates (in spanish):
$ colfeeds-agent "¿Quién ganó las elecciones presidenciales de 2026?"
Según los resultados disponibles en mi archivo de noticias colombianas entre enero y junio de 2026:
**Abelardo De la Espriella** fue declarado ganador del preconteo inicial de la segunda vuelta presidencial celebrada el **21 de junio de 2026**. Iván Cepeda reconoció este resultado preliminar pero anunció que pediría una impugnación y verificación de más de 33 mil mesas electorales (Metropolitano, 2026-06-21).
En la primera vuelta presidencial realizada a finales de mayo de 2026:
- Iván Cepeda obtuvo el primer lugar con **43,74%** de los votos (El Colombiano, 2026-06-01)
- Abelardo De la Espriella obtuvo **40,90%**, lo que permitió pasar a segunda vuelta
Sin embargo, no tengo información definitiva sobre el resultado final confirmado después del escrutinio completo y las impugnaciones. Los resultados preliminares indicaban ganador a Abelardo De la Espriella en la segunda vuelta (Metropolitano, 2026-06-21).
| Stage | Module | Does |
|---|---|---|
| Collect | colfeeds.scrapper.rss_news |
Fetch RSS feeds → timestamped JSONL |
| Clean | colfeeds.cleaner.data_cleaning |
Normalize, deduplicate, recover dates, flag quality |
| Index | colfeeds.indexing.index_builder |
Chunk, embed (bge-m3), load into Qdrant with payload filters |
| Store | colfeeds.indexing.article_storage |
Full article text in SQLite, keyed by article id |
| Serve | colfeeds.agent.agent |
LangGraph agent, one filtered search tool, strict-grounding prompt |
Stack: Ollama (local inference), Qdrant (vector DB), bge-m3 (multilingual
embeddings), LangChain / LangGraph, SQLite. (Local)
# fresh environment (conda or venv)
conda create -n colfeeds python=3.11 -y && conda activate colfeeds
pip install -e .
ollama serve &
ollama pull {model}
ollama pull bge-m3
docker run -d -p 6333:6333 \
-v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrantThe full corpus (~128k articles) is not included in this repo as it is scraped third-party news content. A sample and collector is included:
src/data/sample-feed.jsonl— 200 real records, enough to run the entire cleaning → storage pipeline end-to-end without scraping anything.- The collector, so you can build a corpus of any size from the
92 sources in
config/sites.json.
These generate the directories cleaned_news/ and storage/ to handle data locally.
# clean the 200-record sample into a corpus (expect: kept 200 of 200)
python -m colfeeds.cleaner.data_cleaning src/data/sample-feed.jsonl \
cleaned_news/corpus.jsonl --report cleaned_news/report.json
# with Ollama + Qdrant up, index and query it
colfeeds-index cleaned_news/corpus.jsonl --collection news_sample
colfeeds-agent "..." # ask about something in the sample window# 1. Collect — fetch feeds into timestamped JSONL under data/
colfeeds-collect --output news-run
colfeeds-collect --source eltiempo --output eltiempo-only # single source
# scripts/auto.sh runs this on a schedule via cron
# 2. Clean — normalize + deduplicate a whole directory of raw JSONL
python -m colfeeds.cleaner.data_cleaning ./data cleaned_news/corpus.jsonl \
--report cleaned_news/report.json
# 3. Index — embed and load into Qdrant (+ build the SQLite store)
colfeeds-index cleaned_news/corpus.jsonl
# --limit 1000 --collection news_test for a fast trial run
# 4. Ask
colfeeds-agent "¿Qué pasó con la vía Panamericana?"
colfeeds-agent --chat # interactive, remembers context-
Dense-only retrieval, for now. Embeddings alone (cosine over
bge-m3) handled proper nouns well enough in testing. Sparse/BM25 hybrid is a documented next step, deferred until real proper-noun misses appear rather than added speculatively. -
bge-m3, not an English-first embedder. The corpus is 100% Spanish; a multilingual model measurably outranks English-centric ones here. Single highest-impact retrieval choice. -
One article ≈ one vector. Median article is 390 characters (most RSS feeds do not expose complete articles), so most aren't chunked at all. Only long pieces (live blogs) are split, capped at 10 chunks so a single 74k-character live-blog can't flood results. Query-time parent deduplication is the cleaner long-term fix and is noted.
-
Strict grounding by construction. The agent has exactly one (for now) tool and a system prompt that forbids answering from parametric knowledge; empty retrieval yields a fixed refusal sentence.
temperature=0keeps tool arguments and answers deterministic. -
Local everything. Ollama + Qdrant + SQLite.
Six months of real RSS is messy:
-
Dates. RSS's actual standard is RFC 822 (
Sat, 24 Jan 2026 20:56:36 -0500), whichdatetime.fromisoformatrejects outright and dropping around 30% of records in a naive parser. A tolerant multi-format parser (news_dates.py) recovers them, with adate_sourcefield marking whether a date was parsed or approximated so the agent can exclude approximate dates from strict chronology queries. -
Deduplication. Beyond exact duplicates, the same wire story is republished across many outlets; a fingerprint pass collapses near-duplicates.
A 15-case suite (tests/evaluate_agent.py, tests/evaluation_set.py) covers
five behaviors, scored by rule:
| Category | Cases | Checks |
|---|---|---|
| Grounded factual | 5 | correct answer with source+date citation |
| Refusal, out-of-scope | 3 | refuses even when the model "knows" the answer |
| Refusal, out-of-window | 2 | refuses Colombian topics outside Nov 2025–Jun 2026 |
| Date-filtered | 2 | exercises the since/until retrieval filter |
| Multi-turn | 3 | follow-ups resolve against conversation memory |
Honest limits: scoring is keyword-based, so it confirms answers are on-topic and cited, not that every fact is correct. There are no adversarial "adjacent-but-insufficient" cases yet (where the corpus has related content that shouldn't be treated as an answer).
- Hybrid search when proper-noun misses show up
- Query-time parent deduplication
- Context-window trimming for long multi-turn sessions
- Ragas eval
MIT See LICENSE.
Built with Ollama, Qdrant, LangChain/LangGraph, and bge-m3.

