yuan-de/chat-LCA
LCA ChatBot
A Retrieval-Augmented Generation (RAG) chatbot powered by Claude for answering questions about Life Cycle Assessment (LCA) concepts and methodologies, grounded in the Hauschild et al. textbook "LCA: Theory and Practice".
Overview
LCA ChatBot provides an intelligent conversational interface for exploring LCA concepts. The system combines vector embeddings for semantic search with Claude AI for context-aware answers, ensuring responses are grounded in the source material.
Key capabilities:
- Chat interface with natural language questions
- Source citations showing chapter and page references
- Conversation history for contextual follow-up questions
- RAG pipeline combining retrieval and generation
- MMR (Maximum Marginal Relevance) for diverse, relevant results
- Hybrid GraphRAG: a knowledge graph of LCA concepts augments vector retrieval
- Runtime Retrieval-mode toggle (RAG only vs RAG + Knowledge graph) on both the chat and Report Generator pages
Architecture
The system operates in two distinct phases:
Phase A: Ingestion (Offline)
PDF (Textbook)
-> Extract text
-> Clean and chunk (overlapping segments)
-> Embed with bge-large-en-v1.5
-> Store in Chroma vector databaseRuns once to build the knowledge base. Output is persisted for Phase B.
Phase B: Query (Online)
User Question
-> Retrieve top-10 MMR chunks
-> Format context with source tags
-> Build prompt (system + context + question)
-> Call Claude API
-> Extract answer and sources
-> Display resultsRuns for each user query. Responses include citations.
A one-time offline Phase A2 builds a knowledge graph from the same chunks, which Phase B fuses into retrieval (hybrid GraphRAG — see the dedicated section below). It is bypassable via USE_GRAPH, so the chatbot degrades cleanly to pure-vector RAG when the graph is off or absent.
Requirements
- Python 3.10 or higher
- Virtual environment (recommended)
- Anthropic API key (https://console.anthropic.com/)
- 2GB+ disk space (for embedding model)
Installation
1. Set up Python environment
cd "c:\Users\user\My Project\LCA-ChatBot"
python -m venv .venv
.\.venv\Scripts\Activate.ps1 # Windows PowerShell
# or: source .venv/bin/activate # macOS/Linux2. Install dependencies
pip install -r requirements.txt3. Configure API key
Create .env file in project root:
ANTHROPIC_API_KEY=sk-ant-xxxxxKeep this file private. Do not commit to version control (included in .gitignore).
4. Build vector database (first time only)
python -m phase_a_ingestion.build_indexThis process:
- Extracts text from every PDF registered in
config.BOOKS(under data/raw/) - Chunks text with configurable size and overlap
- Generates embeddings (downloads ~1.3GB model on first run)
- Stores vectors in chroma_db/
Expected time: 5-10 minutes per book on CPU.
5. Start chatbot
streamlit run app/streamlit_app.pyOpens at http://localhost:8501
Usage
Type questions naturally in the chat interface. Examples:
- What is a functional unit in LCA?
- What are the main phases of LCA?
- How is environmental impact calculated?
- What is the difference between cradle-to-grave and cradle-to-cradle?
Each answer includes source citations in the format "Chapter Name, p.XX".
Retrieval mode. The sidebar has a Retrieval mode radio to switch between RAG only (pure vector retrieval) and RAG + Knowledge graph (vector retrieval plus LCA graph relationship facts). It defaults to config.USE_GRAPH and only appears when the graph artifacts under graph/ exist; when the graph is off, the answer is pure-vector RAG and no graph expander is shown. The same toggle is available on the Report Generator page.
Report Generator (PDF → fixed-template carbon footprint report)
The app is multipage. Besides the chat, a Report Generator page (sidebar nav, or the "📄 Report Generator" link on the chat page) turns an uploaded LCA study into a Product Carbon Footprint Study Report that follows the paper's fixed ISO 14067:2018 template (Appendix D).
How it works:
- Upload a PDF (an LCA study or article), pick a model, and choose a Retrieval mode (RAG only vs RAG + Knowledge graph — same sidebar toggle as the chat page).
- The PDF is extracted, cleaned, and chunked with the same Phase A pipeline, then embedded into an ephemeral, in-memory vector store (it never enters
chroma_db/). - A set of per-section retrieval queries (functional unit, system boundary, data sources, carbon footprint by life cycle stage, etc.) pull the most relevant passages via MMR.
- If RAG + Knowledge graph is selected, the LCA concept relationships linked from those section queries are prepended to the study context as an extra grounding block (the graph is built from the reference library, so it supplies canonical LCA domain structure, not facts from the uploaded PDF). The graph connections used are shown in a "🕸️ Knowledge graph" expander.
- A single LLM call fills the fixed template from those passages, marking any field the study doesn't cover as `[Information not provided]` rather than inventing it.
- The report renders as Markdown with download buttons for both `.md` and `.docx` (the Word file is built from the Markdown by phase_c_report/docx_export.py); if the study contains per-stage carbon numbers, a pie chart of the stage shares is shown.
This adapts the paper's third pillar. The paper generated reports via a code-interpreter agent reading a CSV of LCIA results; here the input is an arbitrary PDF processed by RAG, so the carbon-by-stage table is filled only from numbers the study actually contains. Relevant settings live in config.py: REPORT_MAX_TOKENS (larger output cap than the chat's LLM_MAX_TOKENS) and REPORT_TOP_K (chunks per section query). The pipeline is in phase_c_report/ and the page is app/pages/1_Report_Generator.py.
Deployment (Hugging Face Spaces)
The chatbot is hosted live on Hugging Face Spaces: https://huggingface.co/spaces/sshobhit1106/LCA-ChatBot-Streamlit
The Space runs the Docker SDK (HF retired the standalone Streamlit SDK), so Streamlit is launched from a `Dockerfile` rather than auto-detected.
How it works
- Space config lives in the README front matter. The YAML block at the top of this file (
sdk: docker,app_port: 7860, title, emoji, colors) is read by HF to configure the Space. - Container. `Dockerfile` builds on
python:3.11-slim, installsrequirements.txt, copies the app, and starts Streamlit on port 7860 (the port HF Spaces expects). It runs as a non-root user (UID 1000, the HF convention) with a writableHOMEso the bge-large embedding model can cache under~/.cache/huggingfaceon first boot. CORS/XSRF are disabled because the Space is served inside an iframe. - Vector store ships in the image. The pre-built
chroma_db/is committed to the repo so no ingestion runs on the Space — the chatbot is query-ready at boot. Because HF rejects large non-LFS blobs (>10 MB), the Chroma binaries (*.bin,*.sqlite3,*.pickle) are tracked via Git LFS — see `.gitattributes`. - API key.
ANTHROPIC_API_KEYis not committed; it is set as a Space secret in the HF Space settings and read from the environment at runtime (same variable name as the local.env).
Deploying an update
The HF Space is wired up as a git remote named space. Push to it to redeploy:
git push space mainHF rebuilds the Docker image and restarts the Space automatically. To re-add the remote on a fresh clone:
git remote add space https://huggingface.co/spaces/sshobhit1106/LCA-ChatBot-StreamlitIf you rebuild chroma_db/ locally, commit the regenerated LFS-tracked binaries before pushing so the Space serves the updated index.
Project Structure
LCA-ChatBot/
├── phase_a_ingestion/ Offline indexing pipeline
│ ├── build_index.py Main ingestion script
│ ├── extract.py PDF text extraction
│ ├── clean.py Text cleaning
│ ├── chunk.py Text segmentation
│ └── embed.py Embedding model setup
├── phase_a2_graph/ Offline knowledge-graph build (GraphRAG)
│ ├── ontology.py Closed node/relation vocabulary + aliases
│ ├── extract_graph.py LLM triple extraction per chunk
│ └── build_graph.py Canonicalize + assemble networkx graph
├── phase_b_query/ Online query pipeline
│ ├── rag_pipeline.py Main orchestrator (vector + graph fusion)
│ ├── retriever.py Vector search
│ ├── graph_store.py Graph link + traverse + format
│ ├── prompt.py Prompt template
│ └── llm.py LLM setup
├── app/ User interface
│ └── streamlit_app.py Chat UI
├── evaluation/ System evaluation
│ ├── generate_qa.py Generate test Q&A pairs (single-chunk)
│ ├── generate_multihop_qa.py Generate relational 2-hop Q&A pairs (graph-seeded)
│ ├── metrics.py Evaluation metrics
│ └── run_eval.py 3-way: no-RAG vs RAG vs RAG+graph
├── data/
│ ├── raw/ Input data
│ └── processed/ Output artifacts
├── chroma_db/ Vector database (generated, shipped via Git LFS)
├── graph/ Knowledge-graph artifacts (pkl + node embeddings)
├── config.py Global configuration
├── requirements.txt Dependencies
├── Dockerfile Hugging Face Spaces (Docker SDK) image
├── .gitattributes Git LFS rules for chroma_db/ binaries
├── .env API keys (not in git)
└── README.md This file (also holds the HF Space config)Configuration
Edit config.py to customize system behavior:
Development
Rebuild vector database
python -m phase_a_ingestion.build_indexDeletes the existing database and rebuilds the index from every PDF in config.BOOKS. Use when changing CHUNK_SIZE, the embedding model, or replacing a source PDF.
Add a new source without a full rebuild
To register and index an additional PDF without re-embedding everything:
- Drop the PDF in
data/raw/and add an entry toBOOKSinconfig.py(file, a shortbookid, and asourcecitation label). - Embed only the new file and append it to the existing store:
python -m phase_a_ingestion.build_index --add YourFile.pdfThis embeds only the new book's chunks (seconds, not minutes), leaves the other books untouched, and skips any book already present in the store.
Run evaluation
python -m evaluation.generate_qa --num-chunks 50 --pairs-per-chunk 5
python -m evaluation.run_eval --limit 240generate_qa samples book chunks and asks Claude to write self-contained Q&A pairs (≈ num-chunks × pairs-per-chunk total), appended to data/processed/qa_pairs.jsonl. run_eval then answers each pair under three conditions and scores all three:
- no-RAG — the model alone, no context (baseline)
- RAG — vector retrieval only, knowledge graph off
- RAG+graph — hybrid GraphRAG: vector retrieval plus graph relationship facts
The same model produces all three so each comparison is isolated: RAG vs no-RAG measures retrieval; RAG+graph vs RAG measures the graph's incremental gain. Following the paper, BERTScore and ROUGE-L are reported as recall (paper Eq. 4 and Eq. 7 — the values the paper's 0.85 figure uses), alongside cosine similarity. Each eval_predictions.jsonl record now carries baseline, rag, and rag_graph; pairs cached before the graph existed keep their baseline/rag and only the missing rag_graph answer is generated on the next run.
Two shared output files (everything is tagged inside, not split across filenames):
run_eval resumes by (model, question): a pair already answered by a given model is skipped, so re-running re-scores from cache with no new LLM calls. A results row is upserted — re-running the same model+book-filter replaces its row in place. Note eval_predictions.jsonl holds one record per (pair × model evaluated), so its count is the number of answers run — not the number of QA pairs (a book you haven't evaluated contributes zero prediction records).
Choosing the model (and provider)
By default run_eval uses config.LLM_PROVIDER / config.LLM_MODEL. Override per run with --model (provider is inferred: gpt*/o* → openai, claude* → anthropic; pass --provider if it can't be inferred):
python -m evaluation.run_eval --books finkbeiner --model claude-sonnet-4-6
python -m evaluation.run_eval --books finkbeiner --model gpt-5.4QA generation is pinned separately to config.QA_GEN_MODEL (claude-sonnet-4-6) so the test set stays fixed no matter which model you grade.
Generate Q&A for specific books only
When you add new PDFs to config.BOOKS, use --books to generate pairs for just those books without regenerating the ones already covered (new pairs are appended, not overwritten):
python -m evaluation.generate_qa --books ilcd guinee finkbeiner --num-chunks 50 --pairs-per-chunk 5Pass one or more book ids from config.BOOKS. Unknown ids fail loudly. Omit --books to sample across all registered books.
Evaluate specific books only
run_eval takes the same --books filter to score only the pairs tagged with the given book id(s). Results land in the shared files, tagged by book/model inside each record:
python -m evaluation.run_eval --books ilcd # one book
python -m evaluation.run_eval --books ilcd guinee # a subsetOnly pairs that carry a matching book id are selectable. If a requested book has no pairs yet, run_eval stops and points you at generate_qa --books. Omit --books to score across all pairs.
Evaluate a random sample
--limit N always takes the first N pairs (deterministic but book-skewed — the first pairs are all hauschild). To score a random subset instead, use --sample N, which draws N pairs at random after any --books filter:
python -m evaluation.run_eval --sample 50 # random 50 of the 770 pairs
python -m evaluation.run_eval --sample 50 --seed 7 # a different reproducible draw
python -m evaluation.run_eval --sample 50 --books hauschild # random 50 from one bookThe draw is reproducible: --seed (default 42) fixes which pairs are picked, so repeat runs grade the same sample; change the seed for a fresh draw. As with every run, answers are resumed from cache by (model, question), so sampling a fully-cached model (e.g. gpt-5.4) makes no LLM calls — it just re-scores.
A sampled run is print-only: it does not upsert eval_results.json. This is deliberate — a random subset shares the (model, books) key of the model's real full-coverage row, so saving it would silently overwrite the canonical 770-pair numbers. (New answers are still checkpointed to eval_predictions.jsonl, which is keyed by question and safe to append to.) Use --sample for a quick spot-check; use --limit/--books (which do save) for the numbers of record.
Relational / multi-hop subset (testing the graph)
The default QA set draws each pair from one chunk, so its answers live inside a single retrieved passage — the ideal case for pure vector RAG, where the knowledge graph has little to add. To probe the scenario the graph targets — questions that require connecting two facts — build a relational subset with generate_multihop_qa, then score it with run_eval --qa-file:
python -m evaluation.generate_multihop_qa --num 50 # -> data/processed/qa_multihop.jsonl
python -m evaluation.run_eval --qa-file data/processed/qa_multihop.jsonlgenerate_multihop_qa walks the knowledge graph for 2-hop paths A →r1→ B →r2→ C (using only specific relations, skipping the generic related_to/has_property and mega-hub bridges), then grounds a question + reference answer in the actual source passages those edges came from — never in the graph triples, so RAG and RAG+graph are graded on equal footing (no circular advantage for the graph). It appends to qa_multihop.jsonl; delete the file first for a clean regenerate. Each pair records the 2-hop path it was built from.
run_eval --qa-file PATH scores any JSONL under the same 3-way comparison. Like --sample, it is print-only and ignores --books; answers are still cached by (model, question). In practice the graph's KG−RAG gap flips from slightly negative on the general set to consistently positive on this relational set — confirming the graph helps most on multi-hop questions — though the margins are small when the questions are also answerable from the model's own knowledge.
Merged (cross-book) result per model
--merge re-scores a model's cached predictions across all books at once and upserts a single row (with every book listed by name in books). It makes no LLM calls — it only re-runs the metrics on answers already in eval_predictions.jsonl:
python -m evaluation.run_eval --merge # every fully-covered model
python -m evaluation.run_eval --merge --model gpt-5.4 # one modelA merge is only allowed when the model has fully evaluated every book that has QA pairs (its prediction count per book matches that book's pair count). If a book is missing or only partially answered, --merge refuses and names the gap:
Cannot merge — gpt-5.4: not fully evaluated — missing hauschild (0/250), ilcd (0/240).
Evaluate the remaining book(s) first, e.g.
python -m evaluation.run_eval --model gpt-5.4 --books hauschild ilcdWith --model, an incomplete model is a hard error; without it, incomplete models are skipped (with a reason) and only fully-covered ones are merged. Books that have no QA pairs (e.g. a freshly added PDF) are not part of the coverage requirement. Typical workflow: run the per-book evals you want, then run --merge once to refresh the cross-book rows.
How the merge is computed. It does not average the per-book scores. It re-pools every cached prediction across all books into one list and recomputes each metric from scratch over that combined set — i.e. the mean over all N pairs (N = total pairs across the model's books):
merged_metric = (1/N) * Σ score(prediction_i, reference_i)Because the average is taken at the pair level, the merged value is a pair-count-weighted (micro) average of the per-book scores:
merged = Σ_b ( n_b · score_b ) / Σ_b n_bwhere n_b is the number of pairs in book b and score_b is that book's mean. So larger books (e.g. hauschild at 250 pairs) pull the merged number more than small ones (finkbeiner at 40). This is micro-averaging, not a plain mean of the per-book numbers (macro-averaging). The three metrics aggregate this way: BERTScore recall, cosine, and ROUGE-L recall are each averaged over all N pooled pairs (see `evaluation/metrics.py`).
Code quality checks
pylint phase_a_ingestion phase_b_query app
mypy --strict .Evaluation Results
Full results from data/processed/eval_results.json. RAG is compared against an ungrounded no-retrieval baseline (higher is better). RAG+Graph adds the hybrid knowledge-graph layer on top of vector retrieval. All BERTScore values are recall.
RAG outperforms the no-RAG baseline on every metric for both models. Both models clear the pre-set target of BERTScore recall ≥ 0.80 with RAG (0.921 and 0.926). RAG+Graph delivers an additional lift on ROUGE-L for claude-sonnet-4-6 (0.785 vs 0.760) and gpt-5.4 (0.723 vs 0.690), with BERTScore and cosine within rounding of pure-vector RAG.
How RAG Works
Retrieval-Augmented Generation combines three steps:
- Retrieval: Vector similarity search finds passages semantically related to the question
- Augmentation: Relevant passages are inserted into the prompt as grounding context
- Generation: LLM reads context and generates an informed response
This approach constrains answers to the source material, reducing hallucinations and providing verifiable citations.
Knowledge Graph (Hybrid GraphRAG)
On top of pure vector RAG, the chatbot layers a knowledge graph of LCA concepts and their relationships. Vector search retrieves passages that resemble the question; a graph encodes how concepts connect (ReCiPe —covers→ Global Warming, CO₂ —contributes_to→ Global Warming), which helps relational, multi-hop questions whose answer is spread across chunks that individually don't match the query. The two retrievers run together and the graph facts are prepended to the vector context — a strict augmentation, never a replacement.
The whole path is gated by USE_GRAPH in config.py. When it's off, or the graph artifacts under graph/ are missing, the system falls back silently to pure-vector RAG (graph_store.is_available() → False), so it can never regress below the baseline.
Phase A2 — building the graph (offline, one-time)
Built from the same data/processed/chunks.jsonl that Phase A embeds, in phase_a2_graph/:
- Closed ontology (ontology.py) — a small, fixed LCA vocabulary the extractor MUST draw from. The closed set stops the extractor inventing inconsistent labels ("GWP" vs "Global Warming Potential") that fragment the graph. Alongside it is a hand-seeded alias map.
10 node types:
10 relation types:
- Triple extraction (extract_graph.py) — each chunk is sent to Claude Haiku (cheap, pinned via
GRAPH_EXTRACT_MODEL) with a JSON prompt that returns only on-ontology{subject, relation, object}triples grounded in that chunk. Parsing and validation are defensive; anything malformed or off-ontology is dropped, and errors never abort the run. - Canonicalization + assembly (build_graph.py) — apply the alias map, then merge near-duplicate names by BGE embedding cosine ≥ `GRAPH_MERGE_THRESHOLD` (0.92) so "system boundaries" == "system boundary". The result is a
networkx.MultiDiGraph: nodes carry a type, an occurrencecount, and provenance citations; edges carry aweight(how often the fact was asserted → confidence) and citations. Extraction is checkpointed todata/processed/graph_triples.jsonl, so--resumecontinues after any stop.
Three artifacts are persisted to graph/ (committed like chroma_db/): lca_graph.pkl, node_embeddings.npz, node_index.json. The current graph holds ~18.3k nodes and ~36k edges over the four books.
python -m phase_a2_graph.build_graph # full corpus
python -m phase_a2_graph.build_graph --resume # continue after a stop
python -m phase_a2_graph.build_graph --graph-only # rebuild from checkpoint, no LLM callsPhase B — using the graph (per question)
In phase_b_query/graph_store.py, rag_pipeline.answer():
- Links the question to graph nodes — embed it with the same BGE model, keep nodes with cosine ≥
GRAPH_LINK_THRESHOLD(0.55), up toGRAPH_MAX_ENTITIES(5). - Traverses their neighborhood — BFS out to
GRAPH_HOPS(1) in both directions, rank incident facts by edge weight, cap atGRAPH_MAX_FACTS(30). - Formats the facts as compact one-liners and prepends them to the retrieved chunks before the LLM call.
The answer returns the graph_facts it used, shown in the chat under a collapsible "🕸️ Knowledge graph (N connections)" expander.
Per-call toggle (UI + code). answer(..., use_graph=None|True|False) and generate_report(..., use_graph=...) override the graph for a single call: None follows config.USE_GRAPH, True/False force it on/off. The Retrieval mode radio on both the chat and Report Generator pages drives this override, so users can compare RAG vs RAG+graph live without restarting. The radio is shown only when graph_store.has_artifacts() is true (the graph was built); otherwise both pages run pure-vector RAG. The report path uses graph_store.graph_context_multi(SECTION_QUERIES, ...), which pools the entities linked by every section query into one traversal.
Limitations
- Text only — no visuals. Ingestion extracts only the text layer of each PDF (PyMuPDF
get_text), and the embedding model (bge-large-en-v1.5) is text-only. Images, figures, diagrams, charts, and equations are not read or indexed. A figure's caption is captured (it is text on the page), but the figure's visual content is not, so the chatbot cannot answer questions that depend on interpreting a diagram or chart. - Tables are flattened. Tables are captured only as the raw text PyMuPDF reads in reading order, which can be messy or lose structure.
- Scanned / image-only pages are skipped. Pages with no text layer (e.g. scanned documents) produce empty text and are dropped during cleaning, contributing nothing to retrieval.
Dependencies
Core dependencies:
- langchain, langchain-community, langchain-text-splitters: LLM orchestration
- chromadb, langchain-chroma: Vector database
- sentence-transformers, langchain-huggingface: Embeddings (bge-large-en-v1.5)
- langchain-anthropic: Claude API integration
- streamlit: Web UI
- pymupdf, pdfplumber: PDF extraction
Evaluation:
- bert-score, rouge-score: Evaluation metrics
- scikit-learn: Statistical analysis
- matplotlib: Visualization
Utilities:
- python-dotenv: Environment variable loading
- tqdm: Progress bars
See requirements.txt for complete list. Versions are unpinned; freeze with pip freeze > requirements.lock.txt after validation.
Citation
This project builds on the Hauschild et al. textbook:
Hauschild, Michael Z., Ralph K. Rosenbaum, and Stig Irving Olsen.
"LCA: Theory and Practice." Springer, 2018.Troubleshooting
ModuleNotFoundError when running scripts
- Verify virtual environment is activated
- Run from project root directory
- Check sys.path.insert in app/streamlit_app.py
ANTHROPIC_API_KEY not found
- Create .env file in project root
- Add ANTHROPICAPIKEY=sk-ant-xxxxx
- Ensure .env is in .gitignore
Slow first run
- Embedding model downloads on first use (~1.3GB)
- CPU inference is intentionally slow
- Model is cached after first run
Vector database errors
- Delete chroma_db/ directory
- Rebuild with: python -m phaseaingestion.build_index
Streamlit not found
- Run: pip install -r requirements.txt
- Verify requirements.txt is current
License
This project uses copyrighted material (Hauschild et al. textbook) for educational purposes. Refer to the textbook's usage terms.
Support
For issues:
- Review troubleshooting section above
- Check config.py settings match your environment
- Verify all dependencies in requirements.txt are installed
- Ensure .env contains valid ANTHROPICAPIKEY
