Skip to main content
Glama
QuantQJ
by QuantQJ
README.md
# filings-search

Hybrid (BM25 + kNN) **agentic retrieval** over SEC 10-K filings, on **OpenSearch**, with a
Claude tool-use agent that plans its own searches and answers with per-claim citations and a
hard numeric-grounding check. FastAPI surface + TypeScript MCP tool server. Built as the
OpenSearch successor to my Qdrant pipeline; the cite-or-abstain grounding is ported from my
12-domain provenance wrapper.

```
EDGAR ──► connector ──► section splitter ──► chunker ──► embeddings ──► OpenSearch (BM25 + HNSW kNN)
(SEC API)  (ticker→CIK,   (Item 1/1A/1C/7/8…)  (450 tok,    (nomic-embed-text            │
            10-K list,     order-constrained,    60 overlap)  local via Ollama, or        │
            iXBRL strip)   x-ref filtered)                     OpenAI)                     ▼
                                                                          hybrid search (RRF) + filters
                                                                                       │
                                                       ┌───────────────────────────────┼─────────────────────┐
                                                       ▼                               ▼                     ▼
                                              FastAPI /search /ask           TS MCP server            eval harness
                                              /chunk /companies         (search_filings, ask_filings)  (P@k/MRR, LLM-judge)
                                                       ▲
                                              Claude agent (tool loop):
                                              resolve_company → search_filings (item/ticker/FY filters, hybrid|bm25|knn)
                                              → expand_chunk → answer with [c:chunk_id] cites → numeric grounding check
```

## What's in it

| Layer | File | Notes |
|---|---|---|
| Connector | `filings_search/edgar.py` | SEC ticker map + browse-edgar fallback (handles successor-shell CIKs), `data.sec.gov` submissions, primary-doc fetch, iXBRL/HTML strip, disk cache |
| Parsing | `filings_search/parse.py` | 10-K Item splitter: title-verified headings, cross-reference filter, canonical-order + longest-span selection (defeats ToC rows) |
| Chunking | `filings_search/chunk.py` | paragraph-respecting, token-bounded (450/60 overlap), deterministic chunk ids `sha1(accession|item|idx)` |
| Embeddings | `filings_search/embed.py` | `nomic-embed-text` (768-d) on local Ollama by default; OpenAI `text-embedding-3-small` fallback |
| Index | `filings_search/index.py` | OpenSearch 2.19 mapping: english analyzer BM25 field + `knn_vector` (lucene HNSW, cosine) + keyword metadata (ticker, cik, item, fiscal_year, accession…) |
| Retrieval | `filings_search/search.py` | `bm25`, `knn`, `hybrid` (client-side reciprocal-rank fusion), metadata filters, neighbor expansion |
| Agent | `filings_search/agent.py` | Claude (`claude-opus-5`) tool loop; tools: `list_indexed_companies`, `resolve_company`, `search_filings`, `expand_chunk`; answer must cite `[c:id]`; every figure in the answer must appear in a cited chunk or the response is flagged `grounded_numbers=false` |
| API | `filings_search/api.py` | FastAPI: `GET /search`, `POST /ask`, `GET /chunk/{id}?expand=`, `GET /companies`, `GET /health` |
| MCP | `mcp/src/server.ts` | TypeScript stdio MCP server exposing `list_companies`, `search_filings`, `get_chunk`, `ask_filings` |
| Eval | `eval/` | `run_retrieval_eval.py` (hit@1/hit@5/MRR@10 by mode, labeled queries), `run_grounding_eval.py` (numeric grounding + LLM-as-judge citation support) |

## Run it

```bash
docker compose up -d                       # OpenSearch 2.19 (knn + neural plugins), :9200
ollama pull nomic-embed-text               # local embeddings
python3.12 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/python ingest.py --recreate AAPL MSFT NVDA JPM XOM WMT TSLA JNJ   # ~2 min, 8 filings, ~2.1k chunks
./run_api.sh                               # FastAPI on :8801
./.venv/bin/python -m filings_search.agent "What does NVIDIA disclose about export controls to China?"
./.venv/bin/python eval/run_retrieval_eval.py
./.venv/bin/python eval/run_grounding_eval.py
```

MCP (Claude Desktop / Claude Code / Cursor):
```json
{"mcpServers": {"filings-search": {"command": "node", "args": ["/ABS/PATH/filings-search/mcp/dist/server.js"],
                                    "env": {"FILINGS_API_URL": "http://127.0.0.1:8801"}}}}
```

Config via env: `FS_OPENSEARCH_URL`, `FS_INDEX`, `FS_EMBED_BACKEND=ollama|openai`, `FS_AGENT_MODEL`, `FS_JUDGE_MODEL`, `SEC_USER_AGENT`. `ANTHROPIC_API_KEY` (or `~/.env`) for the agent/judge.

## Results (2026-08-18, 8 filings / 2,101 chunks, 28 labeled queries)

Retrieval — see `eval/retrieval_results.json`:

| setting | mode | hit@1 | hit@5 | MRR@10 | avg ms |
|---|---|---|---|---|---|
| unfiltered | bm25 | 0.571 | 0.857 | 0.686 | 5.7 |
| unfiltered | knn | 0.679 | 0.857 | 0.759 | 29.7 |
| unfiltered | hybrid | 0.679 | 0.857 | 0.759 | 37.9 |
| ticker_filtered | bm25 | 0.679 | 0.929 | 0.772 | 3.8 |
| ticker_filtered | knn | 0.679 | 0.893 | 0.779 | 26.6 |
| ticker_filtered | **hybrid** | **0.714** | **0.929** | **0.812** | 34.9 |

Agent grounding — 10 analyst questions, `claude-opus-5` agent + `claude-opus-5` judge (see `eval/grounding_results.json`):

| metric | value |
|---|---|
| numeric-grounded rate (every figure appears in a cited chunk) | **10/10 = 1.00** |
| LLM-judge verdict: grounded / partially grounded / ungrounded | **9 / 1 / 0** |
| mean fraction of claims supported by a cited chunk | **0.968** |
| avg citations per answer | 9.7 |
| avg tool calls per answer (agent-chosen searches/expansions) | 7.6 |
| avg latency | 41 s (13 s simple → 59 s multi-Item) |
| tokens for the 10-question run | 563k in / 23k out |

The one `partially_grounded` (Walmart tariffs, 0.87) was the agent summarizing a mitigation
that the cited chunk states more narrowly — the judge caught it; that is what the judge is for.

## Design notes / honest limitations

- **Agentic ≠ fixed RAG.** The model chooses company, Item, mode, and how many rounds; a fixed
  top-k pipeline gets no second chance. The tool descriptions carry the "when to use" guidance
  (item map, bm25-for-figures, widen-if-empty).
- **Grounding is strict on purpose.** A derived rounding ("$99,779M" → "~$99.8B") is flagged as
  ungrounded; analysts want the figure as filed. Loosen with a tolerance if you disagree.
- **10-K structure quirks are real, not parser bugs**: JPM and XOM are "wrapper" 10-Ks whose
  MD&A/financials sit in a back-of-book Financial Section (labeled under the last Item); NVIDIA
  files statements under Item 15. Eval labels for those are ticker-only. A follow-up is
  F-page detection (`Consolidated Statements of …` headings) to relabel as Item 8.
- **RRF is client-side** — transparent and easy to reason about; OpenSearch's `hybrid` query
  + normalization pipeline is the in-cluster alternative. No cross-encoder re-ranker yet.
- **Single filing per company** in this run; `--filings N` pulls prior years (fiscal_year filter
  already in the mapping and tools).
- No auth, no rate limits, single-node OpenSearch — this is a working vertical, not a deployment.