RAGChatbot / scripts /ingest_content.py
Shurem's picture
Add application file
45c5a08
Raw
History Blame Contribute Delete
9.78 kB
#!/usr/bin/env python3
"""
Content ingestion script for the RAG Chatbot
Parses textbook content, chunks it, generates embeddings, and stores in vector DB
Usage:
cd backend
uv run python -m scripts.ingest_content --source-path ../book-write/docs
Or directly:
uv run python scripts/ingest_content.py --source-path ../book-write/docs
"""
import asyncio
import os
import sys
from pathlib import Path
from typing import List, Dict, Any
import logging
import argparse
import hashlib
import uuid
# Add the backend directory to the path so we can import modules
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
from db.postgres_client import get_session_maker, init_db
from db.models import TextbookContent
from vector.qdrant_client import get_qdrant_manager
from scripts.chunker import ContentChunker
from scripts.embedder import get_embedder
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
async def process_markdown_file(file_path: Path, base_path: Path, chunker: ContentChunker) -> List[Dict[str, Any]]:
"""
Process a single markdown file: read, chunk, and prepare for embedding
"""
logger.info(f"Processing file: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Generate unique chapter ID based on file path
try:
relative_path = file_path.relative_to(base_path).as_posix()
except ValueError:
relative_path = file_path.name
chapter_id = hashlib.md5(relative_path.encode()).hexdigest()[:12]
# Chunk the content
chunks = chunker.chunk_markdown(content, str(file_path))
processed_chunks = []
for chunk in chunks:
# Generate deterministic UUID from content hash (UUID v5 using namespace)
content_hash_str = f"{relative_path}_{chunk['content']}_{chunk['chunk_index']}"
# Use UUID5 with a namespace to generate deterministic UUIDs
embedding_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, content_hash_str)
content_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, f"content_{content_hash_str}")
processed_chunk = {
'textbook_content_id': str(content_uuid),
'chapter_id': chapter_id,
'section_path': relative_path,
'content_text': chunk['content'],
'content_type': 'text',
'metadata_content': {
'source_file': str(file_path),
'section_header': chunk.get('section_header', ''),
'section_level': chunk.get('section_level', 0),
'chunk_index': chunk['chunk_index']
},
'embedding_id': str(embedding_uuid),
'token_count': chunk['token_count'],
}
processed_chunks.append(processed_chunk)
logger.info(f"Processed {len(processed_chunks)} chunks from {file_path}")
return processed_chunks
async def generate_and_store_embeddings(chunks: List[Dict[str, Any]], embedder, qdrant_manager) -> int:
"""
Generate embeddings for chunks and store them in Qdrant
"""
success_count = 0
for chunk in chunks:
try:
# Generate embedding
embedding_vector = await embedder.generate_embedding(chunk['content_text'])
# Store in Qdrant with content in payload
success = await qdrant_manager.store_embedding(
embedding_id=chunk['embedding_id'],
vector=embedding_vector,
textbook_content_id=chunk['textbook_content_id'],
chapter_id=chunk['chapter_id'],
section_path=chunk['section_path'],
token_count=chunk['token_count'],
content_type=chunk['content_type'],
chunk_index=chunk['metadata_content']['chunk_index'],
content=chunk['content_text'] # Include actual text for RAG
)
if success:
success_count += 1
logger.debug(f"Stored embedding: {chunk['embedding_id']}")
else:
logger.error(f"Failed to store embedding: {chunk['embedding_id']}")
except Exception as e:
logger.error(f"Error processing chunk {chunk['embedding_id']}: {e}")
continue
return success_count
async def store_content_metadata(session, chunks: List[Dict[str, Any]]) -> int:
"""
Store content metadata in PostgreSQL
"""
success_count = 0
for chunk in chunks:
try:
db_content = TextbookContent(
id=chunk['textbook_content_id'],
chapter_id=chunk['chapter_id'],
section_path=chunk['section_path'],
content_type=chunk['content_type'],
metadata_content=chunk['metadata_content'],
embedding_id=chunk['embedding_id'],
token_count=chunk['token_count']
)
session.add(db_content)
success_count += 1
except Exception as e:
logger.error(f"Error adding content to session: {e}")
continue
try:
await session.commit()
logger.info(f"Committed {success_count} content records to database")
except Exception as e:
logger.error(f"Failed to commit to database: {e}")
await session.rollback()
return 0
return success_count
async def ingest_content(source_path: str, chunk_size: int = 512, skip_db: bool = False):
"""
Main ingestion function
"""
logger.info(f"Starting content ingestion from: {source_path}")
logger.info(f"Using chunk size: {chunk_size}")
source_dir = Path(source_path).resolve()
if not source_dir.exists():
logger.error(f"Source path does not exist: {source_path}")
return
# Initialize components
chunker = ContentChunker(max_tokens=chunk_size)
embedder = get_embedder()
qdrant_manager = get_qdrant_manager()
# Verify Qdrant connection
logger.info("Checking Qdrant connection...")
if not await qdrant_manager.health():
logger.error("Qdrant connection failed")
return
logger.info("Creating Qdrant collection if it doesn't exist...")
await qdrant_manager.create_collection()
# Initialize database if not skipping
if not skip_db:
logger.info("Initializing database...")
await init_db()
# Find all markdown files
md_files = list(source_dir.rglob("*.md")) + list(source_dir.rglob("*.mdx"))
# Filter out node_modules, .git, etc.
md_files = [f for f in md_files if not any(
part.startswith('.') or part == 'node_modules'
for part in f.parts
)]
logger.info(f"Found {len(md_files)} markdown files to process")
if not md_files:
logger.warning("No markdown files found!")
return
total_chunks = 0
total_embeddings = 0
total_db_records = 0
for md_file in md_files:
try:
# Process the file (chunk it)
chunks = await process_markdown_file(md_file, source_dir, chunker)
total_chunks += len(chunks)
if not chunks:
logger.warning(f"No chunks generated from {md_file}")
continue
# Generate embeddings and store in Qdrant
embeddings_stored = await generate_and_store_embeddings(chunks, embedder, qdrant_manager)
total_embeddings += embeddings_stored
# Store metadata in PostgreSQL (optional)
if not skip_db:
session_maker = get_session_maker()
async with session_maker() as db_session:
db_stored = await store_content_metadata(db_session, chunks)
total_db_records += db_stored
logger.info(f"Completed: {md_file.name} - {len(chunks)} chunks, {embeddings_stored} embeddings")
except Exception as e:
logger.error(f"Error processing file {md_file}: {e}")
import traceback
traceback.print_exc()
continue
logger.info("=" * 60)
logger.info("Content ingestion completed!")
logger.info(f"Total chunks processed: {total_chunks}")
logger.info(f"Total embeddings stored in Qdrant: {total_embeddings}")
if not skip_db:
logger.info(f"Total records stored in PostgreSQL: {total_db_records}")
logger.info("=" * 60)
def main():
parser = argparse.ArgumentParser(
description='Ingest textbook content for RAG Chatbot',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Ingest from book-write/docs directory
uv run python scripts/ingest_content.py --source-path ../book-write/docs
# Ingest with custom chunk size
uv run python scripts/ingest_content.py --source-path ../book-write/docs --chunk-size 256
# Skip PostgreSQL storage (only store in Qdrant)
uv run python scripts/ingest_content.py --source-path ../book-write/docs --skip-db
"""
)
parser.add_argument('--source-path', type=str, required=True,
help='Path to the source textbook content (e.g., ../book-write/docs)')
parser.add_argument('--chunk-size', type=int, default=512,
help='Maximum tokens per content chunk (default: 512)')
parser.add_argument('--skip-db', action='store_true',
help='Skip PostgreSQL storage, only store embeddings in Qdrant')
args = parser.parse_args()
# Run the async ingestion function
asyncio.run(ingest_content(args.source_path, args.chunk_size, args.skip_db))
if __name__ == "__main__":
main()