finchat-api / src /ingest.py
dahutapea's picture
Add hybrid RAG: XBRL structured-financials layer for numeric questions
8db8a8c
Raw
History Blame Contribute Delete
4.73 kB
"""Build the FinChat vector store from recent SEC 10-K filings (EDGAR).
Pipeline:
fetch each target 10-K (edgartools) -> split into chunks
-> embed locally -> store in Chroma (persisted to config.VECTORSTORE_DIR)
Run once, from the project root:
python -m src.ingest
"""
from __future__ import annotations
import shutil
import sys
import time
from pathlib import Path
# Allow running as either `python -m src.ingest` or `python src/ingest.py`.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from edgar import Company, set_identity
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from src import config
from src.financials import financial_documents
# Windows consoles default to cp1252 and crash when print() emits Unicode
# (arrows, em-dashes, curly quotes from filings). Force UTF-8 output.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def get_filing(ticker: str, fiscal_year: int):
"""Return the 10-K filing whose fiscal period matches fiscal_year, else None.
Matches on the filing's period_of_report year, so an offset fiscal year
(e.g. Amcor's June close) still resolves to the right filing.
"""
for f in Company(ticker).get_filings(form="10-K"):
period = getattr(f, "period_of_report", None)
if period and str(period)[:4] == str(fiscal_year):
return f
return None
def fetch_documents() -> list[Document]:
"""Fetch every target filing -> text chunks + structured financial facts."""
set_identity(config.EDGAR_IDENTITY)
splitter = RecursiveCharacterTextSplitter(
chunk_size=config.CHUNK_SIZE,
chunk_overlap=config.CHUNK_OVERLAP,
)
docs: list[Document] = []
for ticker, name, year in config.TARGET_FILINGS:
print(f"Fetching {ticker} FY{year} 10-K ...", end=" ", flush=True)
filing = get_filing(ticker, year)
if filing is None:
print("NOT FOUND -- skipping")
continue
# 1) Filing TEXT -> chunks (for qualitative questions).
text = filing.text()
source = f"{name} 10-K (FY{year})"
for chunk in splitter.split_text(text):
docs.append(
Document(
page_content=chunk,
metadata={
"ticker": ticker,
"company": name,
"year": str(year),
"accession": filing.accession_no,
"source": source,
"type": "text",
},
)
)
# 2) XBRL FINANCIAL STATEMENTS -> structured facts (numeric questions).
fin_docs = financial_documents(filing, ticker, name, year)
docs.extend(fin_docs)
print(f"{len(text):,} chars + {len(fin_docs)} financial statements "
f"-> {len(docs)} docs so far")
time.sleep(0.5) # be polite to SEC's servers
return docs
def _safe_rmtree(path: Path, retries: int = 3) -> None:
"""Delete a directory, retrying briefly through transient file locks."""
for _ in range(retries):
try:
shutil.rmtree(path)
return
except (PermissionError, OSError):
time.sleep(1.0)
shutil.rmtree(path)
def build_vectorstore(chunks: list[Document]) -> None:
if config.VECTORSTORE_DIR.exists():
print("Removing existing vector store ...")
_safe_rmtree(config.VECTORSTORE_DIR)
config.VECTORSTORE_DIR.parent.mkdir(parents=True, exist_ok=True)
print(f"Loading embedding model {config.EMBEDDING_MODEL} ...")
embeddings = HuggingFaceEmbeddings(model_name=config.EMBEDDING_MODEL)
print(f"Embedding & storing {len(chunks)} chunks (a few minutes) ...")
Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=config.CHROMA_COLLECTION,
persist_directory=str(config.VECTORSTORE_DIR),
)
print(f"Done. Vector store saved to: {config.VECTORSTORE_DIR}")
def build_index() -> None:
"""Full pipeline: fetch filings -> chunk -> embed -> store.
Importable so the app can bootstrap the store on first run.
"""
docs = fetch_documents()
if not docs:
raise SystemExit("No filings were fetched — check tickers/years in config.py.")
print(f"Total: {len(docs)} chunks from {len(config.TARGET_FILINGS)} target filings.")
build_vectorstore(docs)
def main() -> None:
build_index()
if __name__ == "__main__":
main()