Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
# Окружение
.venv/

# Python
__pycache__/
*.py[cod]
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
dist/
build/
.pytest_cache/
.venv/
venv/

# Сырые данные (скачиваются с Kaggle локально)
data/raw/rows.csv
data/raw/archive.zip
.uv/

# Артефакты pipeline (генерируются скриптами)
data/processed/
data/index/
data/processed/*.jsonl

# Секреты
.env
.env.*

# IDE и ОС
data/raw/datasets.json


.DS_Store
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
170 changes: 132 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# RAG Tutorial
# Yelp Reviews RAG

Учебный RAG на текстовых описаниях: TF-IDF + demo-ответ с источниками.
Pipeline: данные → чанкииндекс → поискответ.
Учебный Retrieval-Augmented Generation поверх отзывов **Yelp**: данные → чанки →
индекс → поискdemo-ответ с источникамиStreamlit UI.

**Документы разработки:** [doc/tasklist.md](doc/tasklist.md) · **Данные:** [doc/DATA.md](doc/DATA.md) · **Домашнее задание:** [homework/README.md](homework/README.md) · **Слайды:** [Seminar_Big_data.pdf](Seminar_Big_data.pdf) · **Kaggle:** [Consumer Complaint Database](https://www.kaggle.com/datasets/datasnaek/consumer-complaint-database)
Pipeline повторяет архитектуру репозитория-образца
[MaratNotes/rag-tutorial](https://github.com/MaratNotes/rag-tutorial) и расширяет
его двумя улучшениями:

1. **Semantic-поиск** через `sentence-transformers` (рядом с базовым TF-IDF, с
переключением бэкенда).
2. **Оценка качества (Eval)** — метрики `Recall@k` и `MRR@k` со сравнением
бэкендов на эталонном наборе вопросов.

## Требования

Expand All @@ -17,63 +24,141 @@ Pipeline: данные → чанки → индекс → поиск → отв
uv venv
uv sync

# 2. Сборка индекса (ingest + chunk + TF-IDF)
# 2. Подготовка данных: скачать ~5000 отзывов Yelp с Hugging Face
uv run python scripts/prepare_datasets.py --limit 5000

# 3. Сборка индекса (ingest + chunk + TF-IDF + semantic embeddings)
uv run python scripts/build_index.py

# 3. Запуск UI
# 4. Запуск UI
uv run streamlit run app/main.py
```

Откройте в браузере: http://localhost:8501

> **Офлайн / нет доступа к Hugging Face?**
> Данные: `uv run python scripts/prepare_datasets.py --synthetic --limit 5000`
> сгенерирует реалистичный синтетический корпус того же формата.
> Индекс: если модель эмбеддингов недоступна, `build_index.py` автоматически
> пропускает semantic-индекс и система работает на TF-IDF (см. логику отката).

## Выбор бэкенда поиска

Бэкенд задаётся переменной окружения `RAG_BACKEND` (по умолчанию `semantic`):

```bash
RAG_BACKEND=semantic uv run streamlit run app/main.py # поиск по смыслу
RAG_BACKEND=tfidf uv run streamlit run app/main.py # лексический поиск
```

Если выбран `semantic`, но эмбеддинги не собраны, происходит автоматический
откат на TF-IDF.

## Данные

Подробности — в [doc/DATA.md](doc/DATA.md). Кратко:

- **Источник:** [`Yelp/yelp_review_full`](https://huggingface.co/datasets/Yelp/yelp_review_full)
(Yelp Dataset Challenge 2015), 650 000 отзывов с оценкой 1–5 звёзд.
- **Что берём:** случайный срез из 5000 отзывов (seed=42).
- **Что индексируется:** текст отзыва + выведенная по ключевым словам категория
заведения + рейтинг, упакованные в одну запись `datasets.json`.
- **Масштаб:** 5000 записей → **14975 чанков**

## Demo-вопросы

В sidebar приложения или в поле ввода:
| № | Вопрос | Ожидание |
|---|--------|----------|
| 1 | `rude bartender and watered down drinks` | ответ, категория «Бары», score > 0 |
| 2 | `delicious food and great service` | ответ, релевантные ресторанные отзывы |
| 3 | `honest mechanic fast oil change` | ответ, категория «Авто», высокий score |
| **N (negative)** | `quantum entanglement in particle physics` | **отказ** (темы нет в отзывах) |

| Вопрос | Ожидание |
|--------|----------|
| **Ипотека - закрытие ипотечной сделки** | ответ, doc_id=2, score > 0.4 |
| Какие переменные в датасете про безработицу? | отказ (нет таких данных) |
| За какой период данные об инфляции? | отказ |
| Как приготовить борщ? | отказ |
Запустить демо из консоли:

Другие рабочие запросы: `студенческий кредит`, `Capital One`, `Wells Fargo закрытие счёта`.
```bash
uv run python scripts/check_generator.py
```

## Проверка из консоли

```bash
# Тесты
# Тесты (15 шт.)
uv run pytest tests/ -v

# Поиск (итерация 5)
uv run python scripts/check_retrieval.py

# Demo-ответ (итерация 6)
# Demo-ответ + negative (итерация 6)
uv run python scripts/check_generator.py

# Оценка качества: Recall@k, MRR@k, сравнение бэкендов
uv run python scripts/eval.py
```

## Логи запусков

Полные логи всех проверок: [docs/logs/checks_tfidf.md](docs/logs/checks_tfidf.md).
Ключевые выдержки:

**Сборка индекса:**

```
Документов: 5000, чанков: 14975, TF-IDF матрица: (14975, 324), бэкенды: TF-IDF + semantic
```

**Поиск (negative-вопрос корректно даёт score 0):**

```
Запрос: «how to configure a wifi router at home»
[1] doc_id=4999, score=0.0000
[2] doc_id=4998, score=0.0000
[3] doc_id=4997, score=0.0000
```

**Тесты:**

```
============================== 15 passed in 1.51s ==============================
```

![Сравнение бэкендов TF-IDF vs semantic](docs/screenshots/eval.png)

![UI с ответом на demo-вопрос](docs/screenshots/ui.png)

**Оценка качества (Eval):** на 16 эталонных вопросах
TF-IDF — Recall@3 = 0.812, MRR@3 = 0.677;
semantic — Recall@3 = 0.938, MRR@3 = 0.812
(Δ +0.125 / +0.135 в пользу semantic-поиска).

UI демонстрирует pipeline на бэкенде TF-IDF (стабильно работает на любой машине).
Преимущество semantic-поиска измерено и показано в выводе scripts/eval.py.

## Структура проекта

```
rag-tutorial/
yelp-rag/
├── app/
│ ├── config.py # пути, top_k, размер чанка
│ ├── chunker.py # нарезка текста
│ ├── retriever.py # TF-IDF + cosine top-k
│ ├── generator.py # demo-ответ
│ ├── prompts.py # правила и отказы
│ └── main.py # Streamlit UI
│ ├── config.py # пути, top_k, размер чанка, выбор бэкенда
│ ├── chunker.py # нарезка текста на чанки с overlap
│ ├── retriever.py # TF-IDF и semantic бэкенды + единый интерфейс
│ ├── generator.py # demo-ответ из найденных чанков + отказ
│ ├── prompts.py # правила, тексты отказов, пороги релевантности
│ └── main.py # Streamlit UI (индикатор бэкенда, порог, top-k)
├── scripts/
│ ├── ingest.py
│ ├── build_index.py
│ ├── check_retrieval.py
│ └── check_generator.py
│ ├── prepare_datasets.py # Yelp с Hugging Face или синтетический фолбэк
│ ├── ingest.py # datasets.json -> documents.jsonl
│ ├── build_index.py # сборка TF-IDF + semantic индексов
│ ├── check_retrieval.py # ручная проверка поиска
│ ├── check_generator.py # 3 demo-вопроса + 1 negative
│ └── eval.py # Recall@k, MRR@k, сравнение бэкендов
├── data/
│ ├── raw/datasets.json
│ ├── processed/ # documents.jsonl, chunks.jsonl (генерируются)
│ └── index/ # vectorizer.pkl, matrix.npz (генерируются)
├── tests/
└── doc/
│ ├── raw/datasets.json # генерируется prepare_datasets.py
│ ├── processed/ # documents.jsonl, chunks.jsonl (генерируются)
│ └── index/ # vectorizer.pkl, matrix.npz, embeddings.npy (генерируются)
├── tests/ # 15 тестов: chunking, retrieval, eval
├── doc/ # документы планирования + DATA.md
└── docs/logs/ # сохранённые логи запусков
```

## Пересборка индекса
Expand All @@ -84,11 +169,20 @@ rag-tutorial/
uv run python scripts/build_index.py
```

## Ограничения MVP
## Реализованные улучшения

Подробности и план — в [doc/IMPROVEMENTS.md](doc/IMPROVEMENTS.md). Реализовано:

- **Semantic embeddings** (`app/retriever.py`, `scripts/build_index.py`):
поиск по смыслу через `all-MiniLM-L6-v2`. Ловит синонимы и перефразирование,
где TF-IDF промахивается.
- **Eval-метрики** (`scripts/eval.py`, `tests/test_eval.py`): Recall@k и MRR@k
на эталонном наборе, сравнение TF-IDF vs semantic в числах.

- Поиск по **словам** (TF-IDF), не по смыслу — синонимы могут не находиться.
- Demo-режим: ответ из найденных чанков, без внешней LLM.
- Индексируется только текст описаний, CSV не используется.
## Ограничения

## Контакты
Подписывайтесь на канал: @Marat_notes
- TF-IDF ищет по словам, не по смыслу: синонимы могут не находиться (это и
устраняет semantic-бэкенд).
- Demo-режим: ответ собирается из найденных чанков без внешней LLM.
- Категория заведения выводится эвристически по ключевым словам (в исходном
Yelp Review Full поля категории нет).
9 changes: 1 addition & 8 deletions app/chunker.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
"""Chunking: documents.jsonl → chunks.jsonl."""

import json
from pathlib import Path

from app.config import CHUNK_MAX_CHARS, CHUNK_OVERLAP, DOCUMENTS_JSONL, CHUNKS_JSONL
from app.config import CHUNK_MAX_CHARS, CHUNK_OVERLAP, CHUNKS_JSONL, DOCUMENTS_JSONL


def split_paragraphs(text: str) -> list[str]:
"""Разбивает текст на непустые абзацы."""
return [p.strip() for p in text.split("\n\n") if p.strip()]


def split_long_text(text: str, max_chars: int) -> list[str]:
"""Длинный абзац без переносов — жёсткая нарезка; overlap добавляется позже."""
if len(text) <= max_chars:
return [text]
parts: list[str] = []
Expand All @@ -25,7 +21,6 @@ def split_long_text(text: str, max_chars: int) -> list[str]:


def apply_overlap(chunks: list[str], overlap: int, max_chars: int) -> list[str]:
"""Добавляет overlap из предыдущего чанка в начало следующего."""
if overlap <= 0 or len(chunks) <= 1:
return chunks
result = [chunks[0]]
Expand All @@ -43,7 +38,6 @@ def chunk_text(
max_chars: int = CHUNK_MAX_CHARS,
overlap: int = CHUNK_OVERLAP,
) -> list[str]:
"""Нарезка по абзацам с ограничением длины и overlap между чанками."""
if not text.strip():
return []

Expand Down Expand Up @@ -73,7 +67,6 @@ def flush() -> None:


def chunk_document(doc: dict) -> list[dict]:
"""Один документ → список чанков с метаданными."""
chunks = []
for i, text in enumerate(chunk_text(doc["text"])):
chunks.append(
Expand Down
13 changes: 13 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@


import os
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
Expand All @@ -12,8 +15,18 @@

VECTORIZER_PKL = DATA_INDEX / "vectorizer.pkl"
MATRIX_NPZ = DATA_INDEX / "matrix.npz"

EMBEDDINGS_NPY = DATA_INDEX / "embeddings.npy"
EMBED_MODEL_TXT = DATA_INDEX / "embed_model.txt"

INDEX_CHUNKS_JSONL = DATA_INDEX / "chunks.jsonl"

TOP_K = 3
CHUNK_MAX_CHARS = 400
CHUNK_OVERLAP = 50

RETRIEVAL_BACKEND = os.environ.get("RAG_BACKEND", "semantic").strip().lower()

EMBED_MODEL_NAME = os.environ.get(
"RAG_EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
)
15 changes: 6 additions & 9 deletions app/generator.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
"""Demo-ответ: top-k чанки -> текст + источники (без внешней LLM)."""

from app.config import TOP_K
from app.prompts import MIN_SCORE, REFUSAL_EMPTY_QUESTION, REFUSAL_NO_CONTEXT
from app.prompts import REFUSAL_EMPTY_QUESTION, REFUSAL_NO_CONTEXT, min_score_for
from app.retriever import Retriever


def build_answer(hits: list[dict]) -> str:
"""Формирует ответ только из чанков с score > 0."""
relevant = [h for h in hits if h["score"] >= MIN_SCORE]
def build_answer(hits: list[dict], min_score: float) -> str:
relevant = [h for h in hits if h["score"] >= min_score]
if not relevant:
return REFUSAL_NO_CONTEXT

parts = ["На основании найденных фрагментов:"]
parts = ["На основании найденных отзывов:"]
for i, hit in enumerate(relevant, 1):
parts.append(f"\n[{i}] {hit['name']}")
parts.append(f"doc_id={hit['doc_id']}, score={hit['score']:.2f}")
Expand All @@ -36,13 +33,13 @@ def ask(
k: int = TOP_K,
retriever: Retriever | None = None,
) -> dict:
"""Вопрос -> ответ и список источников."""
if not question.strip():
return {"answer": REFUSAL_EMPTY_QUESTION, "sources": []}

r = retriever or Retriever()
hits = r.search(question.strip(), k=k)
min_score = min_score_for(r.backend_name)
return {
"answer": build_answer(hits),
"answer": build_answer(hits, min_score),
"sources": format_sources(hits),
}
Loading