byteastra / scripts /ingest.py
risu1012's picture
feat: optimize deployment payload using zipped database index
59045dd
Raw
History Blame Contribute Delete
6.11 kB
"""
ByteAstra — Textbook Ingestion Script.
Reads source documents (plain text or markdown files), chunks them, and
indexes the chunks into ChromaDB under the specified domain's collection.
Usage:
python scripts/ingest.py --domain ayurveda --source-dir ./data/ayurveda
Arguments:
--domain Domain key (must match a YAML in app/domains/)
--source-dir Directory containing .txt or .md source files
--chunk-size Token-approximate chunk size in characters (default: 800)
--overlap Character overlap between consecutive chunks (default: 100)
--batch-size Upsert batch size for ChromaDB (default: 64)
--dry-run Parse and chunk without writing to ChromaDB
"""
import argparse
import hashlib
import logging
import sys
from pathlib import Path
# Allow running from the backend/ directory
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.config import get_settings
from app.services.rag import index_chunks, get_or_create_collection
logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
logger = logging.getLogger(__name__)
# ── Chunking ───────────────────────────────────────────────────────────────────
def chunk_text(
text: str,
chunk_size: int = 800,
overlap: int = 100,
) -> list[str]:
"""
Simple character-window chunker.
Splits on paragraph boundaries where possible to preserve semantic units.
"""
paragraphs = text.split("\n\n")
chunks: list[str] = []
current = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current) + len(para) + 2 <= chunk_size:
current += ("\n\n" if current else "") + para
else:
if current:
chunks.append(current)
# If paragraph itself exceeds chunk_size, hard-split it
if len(para) > chunk_size:
for i in range(0, len(para), chunk_size - overlap):
chunks.append(para[i : i + chunk_size])
else:
current = para
if current:
chunks.append(current)
return chunks
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""
Parse optional YAML-style frontmatter from a markdown file.
Returns (metadata_dict, body_text).
Frontmatter format (optional):
---
source: Charaka Samhita
chapter: Sutrasthana
section: 1.1
---
"""
meta: dict = {}
if text.startswith("---"):
lines = text.splitlines()
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end_idx = i
break
if end_idx:
import yaml
try:
meta = yaml.safe_load("\n".join(lines[1:end_idx])) or {}
except Exception:
pass
text = "\n".join(lines[end_idx + 1:]).strip()
return meta, text
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Ingest textbooks into ByteAstra ChromaDB")
parser.add_argument("--domain", required=True, help="Domain key, e.g. 'ayurveda'")
parser.add_argument("--source-dir", required=True, type=Path, help="Directory of .txt/.md files")
parser.add_argument("--chunk-size", type=int, default=800)
parser.add_argument("--overlap", type=int, default=100)
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--dry-run", action="store_true", help="Parse without writing to DB")
args = parser.parse_args()
source_dir: Path = args.source_dir
if not source_dir.is_dir():
logger.error("Source directory not found: %s", source_dir)
sys.exit(1)
files = list(source_dir.glob("**/*.txt")) + list(source_dir.glob("**/*.md"))
if not files:
logger.warning("No .txt or .md files found in %s", source_dir)
sys.exit(0)
collection_name = f"domain_{args.domain}"
logger.info("Ingesting into collection: %s", collection_name)
logger.info("Found %d source files", len(files))
all_chunks: list[dict] = []
for file_path in sorted(files):
logger.info("Processing: %s", file_path.name)
raw = file_path.read_text(encoding="utf-8", errors="ignore")
meta, body = parse_frontmatter(raw)
source = meta.get("source", file_path.stem)
chapter = meta.get("chapter", "")
section = meta.get("section", "")
text_chunks = chunk_text(body, chunk_size=args.chunk_size, overlap=args.overlap)
logger.info(" → %d chunks", len(text_chunks))
for i, chunk_text_str in enumerate(text_chunks):
# Deterministic ID based on content hash — safe to re-run (upsert)
chunk_hash = hashlib.md5(chunk_text_str.encode()).hexdigest()[:12]
chunk_id = f"{args.domain}_{file_path.stem}_{i:04d}_{chunk_hash}"
all_chunks.append({
"id": chunk_id,
"text": chunk_text_str,
"source": source,
"chapter": str(chapter),
"section": str(section),
})
logger.info("Total chunks prepared: %d", len(all_chunks))
if args.dry_run:
logger.info("DRY RUN — no data written to ChromaDB.")
for c in all_chunks[:3]:
print(f"\n[{c['id']}]\n{c['text'][:200]}...")
return
# Batch upsert
for i in range(0, len(all_chunks), args.batch_size):
batch = all_chunks[i : i + args.batch_size]
index_chunks(collection_name, batch)
logger.info("Indexed batch %d–%d", i + 1, i + len(batch))
logger.info("✓ Ingestion complete. %d chunks in '%s'", len(all_chunks), collection_name)
if __name__ == "__main__":
main()