filings-search
Enables AI agents to search, retrieve, and analyze SEC 10-K filings with hybrid BM25+kNN retrieval, per-claim citations, and strict numeric grounding verification.
README
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 |
| 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
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):
{"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
hybridquery- normalization pipeline is the in-cluster alternative. No cross-encoder re-ranker yet.
- Single filing per company in this run;
--filings Npulls 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.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.