All checks were successful
CI / test (pull_request) Successful in 1m52s
Adds --doc (repeatable, file or directory) so the researcher can draw on user-supplied documents alongside the web: documents.rs resolves paths into embeddable text, retrieval.rs embeds them with a dedicated embedding model (nomic-embed-text, separate from the chat models used elsewhere) into an in-memory vector index and retrieves the excerpts most relevant to the topic once up front, and researcher.rs folds those excerpts into the researcher's task under the same footnote-citation scheme already used for web sources. The embedding and retrieval phases show progress the same way every other phase does — a spinner while embedding, a summary line once excerpts are retrieved, tracing spans for -l mode. Verified against a live Ollama nomic-embed-text pull and a real research round: a planted fact sheet was correctly ranked as the most relevant of several embedded documents and appeared in the researcher's task before its first turn.
124 lines
5.6 KiB
Markdown
124 lines
5.6 KiB
Markdown
# 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: 2025–2026 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](./docs/case-study.md) 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](https://ollama.com) and a
|
||
self-hosted [SearXNG](https://searx.space) 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](https://ollama.com) running locally with a tool-calling-capable
|
||
model pulled (the researcher and reviewer/writer models are configured in
|
||
[`src/models.rs`](./src/models.rs))
|
||
- A local [SearXNG](https://docs.searxng.org/) 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`](./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.
|