No description
Find a file
Austin Schaefer ff9f9455a6
All checks were successful
CI / test (pull_request) Successful in 11m5s
Chunk uploaded documents instead of truncating them at 20K chars
A flat 20K-char cutoff (copied from the fetched-web-page limit) silently
dropped everything past the first ~20KB of a larger file, and even
within the cutoff, embedding a whole multi-page document as one vector
made retrieval coarse — the vector just averages out whatever topics
the document covers.

Split each file into ~1500-char chunks via text-splitter (recursive
semantic-boundary splitting: paragraph > sentence > word, never mid-word)
and embed each chunk as its own document, tagged with its source and
part number. This removes the practical size ceiling — a large file
chunks the same way a short one does — and sharpens retrieval by letting
it surface the specific passage relevant to a query. It also incidentally
caps the worst-case retrieval payload: 5 chunks now tops out around
7500 chars versus the old worst case of 5 full 20K-char documents.

Verified against a live Ollama nomic-embed-text pull: a short document
still embeds as a single chunk, unchanged from before.
2026-08-18 14:43:14 +02:00
.forgejo/workflows Fix CI running twice per PR commit 2026-08-18 13:55:01 +02:00
docs Extract swear_cleanup to its own repo, flatten deep_research to root 2026-08-18 13:43:26 +02:00
src Chunk uploaded documents instead of truncating them at 20K chars 2026-08-18 14:43:14 +02:00
.gitignore feat: Introduce two agent flow with profanity verification. 2026-08-05 14:21:38 +02:00
Cargo.lock Chunk uploaded documents instead of truncating them at 20K chars 2026-08-18 14:43:14 +02:00
Cargo.toml Chunk uploaded documents instead of truncating them at 20K chars 2026-08-18 14:43:14 +02:00
README.md Add document embedding and retrieval via a dedicated embedding model 2026-08-18 14:27:54 +02:00

deep_research

A local-first, multi-agent deep-research CLI: give it a topic, it searches the web, cross-checks what it finds, and writes up a cited report — entirely on infrastructure you control, with no cloud LLM API key and no query ever leaving your machine.

$ deep_research "trends in AI customer-support chatbots"
🔎 Researching...
🧐 Reviewing findings...
✍️  Writing report...

# AI Customer-Support Chatbots: 20252026 Trends
...

Why this exists

This started as a "does deep research actually work end-to-end" exercise and turned into a small case study in building an agentic system that survives contact with reality: models that hit their turn budget mid-task, search providers that rate-limit, and reviewers that reject good-faith work. The case study walks through what broke and how each failure was fixed, not just papered over.

Architecture

Four small agents, each with one job, coordinated by plain Rust control flow — not a framework's agent graph, not an LLM deciding when to stop:

┌─────────────┐  approve/reject  ┌──────────┐
│  researcher │ ───────────────► │ reviewer │
│ (tool-using)│ ◄─────────────── │          │
└──────┬──────┘   gaps/feedback  └────┬─────┘
       │ turn budget exhausted        │ approved,
       │ mid-investigation            │ or out of rounds
       ▼                              ▼
┌──────────────┐              ┌──────────────┐
│ summarizer   │──findings───►│    writer    │──► report
│ (recovery)   │              │              │
└──────────────┘              └──────────────┘
  • researcher — a tool-calling agent (search_web, fetch_page) that gathers and cross-checks evidence, capped at a fixed model-call budget so a confused model can't loop forever.
  • reviewer — a separate, fresh-context agent that checks the researcher's conclusions actually follow from its cited sources, and either approves the findings or hands back concrete gaps for another pass.
  • writer — turns approved (or partial) findings into a structured, footnoted report, streamed to the terminal as it's generated.
  • summarizer (recovery path) — only runs when the researcher exhausts its turn budget before concluding on its own. It reconstructs a proper findings dump from the raw tool-call transcript rather than the run simply failing; see the case study for why this exists and how it degrades gracefully if the summarizer call itself fails.

Everything runs against local models via Ollama and a self-hosted SearXNG instance for search — no OpenAI/ Anthropic/Google API key, no third-party search API, nothing about the research topic leaves the host it runs on. That's a deliberate constraint, not a limitation: it's the same shape a privacy-sensitive customer deployment would need.

Running it

Prerequisites:

  • Ollama running locally with a tool-calling-capable model pulled (the researcher and reviewer/writer models are configured in src/models.rs)
  • A local SearXNG instance with its JSON API enabled (defaults to http://localhost:8080, overridable via SEARXNG_URL)
cargo run -- "your research topic"

# or, with tracing spans on stderr instead of the progress spinner:
cargo run -- -l info "your research topic"

# give the researcher your own documents to draw on, alongside the web —
# repeatable, and a directory contributes every file directly inside it
# (one level deep, not recursive):
cargo run -- --doc ./notes.txt --doc ./research-docs/ "your research topic"

Uploaded documents are embedded with a dedicated embedding model (see EMBEDDING_MODEL in src/models.rs) into an in-memory vector index, then the excerpts most relevant to the topic are retrieved and folded into the researcher's task alongside anything it finds on the web — the same footnote-citation scheme applies to both.

Project layout

Split one concern per file rather than one large module:

File Responsibility
main.rs Argument parsing, logging setup, and the single top-level call — no orchestration logic
research.rs The research/review round loop
researcher.rs The tool-calling research phase
review.rs The reviewer agent
summarizer.rs Max-turns recovery: reconstructs findings via a model call
writer.rs Turns findings into the final streamed report
history.rs Pure, unit-tested helpers for parsing a rig chat history into usable text
documents.rs Resolves --doc paths into embeddable documents
retrieval.rs Embeds documents into an in-memory vector index and retrieves relevant excerpts
tools.rs search_web (SearXNG) and fetch_page tool implementations
stream.rs Drains a streaming prompt response to the terminal
progress.rs The terminal spinner and per-phase emoji
models.rs, cli.rs, observability.rs Small shared config: model names, CLI args, tracing setup

Testing

cargo test               # unit tests — pure functions, no network
cargo test -- --ignored  # + a live smoke test against SearXNG
cargo clippy --all-targets

CI (.forgejo/workflows/ci.yml) runs formatting, lint, build, and the unit test suite on every push and PR.