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
246 changes: 152 additions & 94 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,152 @@
# RAG Tutorial

Учебный RAG на текстовых описаниях: TF-IDF + demo-ответ с источниками.
Pipeline: данные → чанки → индекс → поиск → ответ.

**Документы разработки:** [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)

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

- Python 3.10+
- [uv](https://docs.astral.sh/uv/)

## Быстрый старт

```bash
# 1. Окружение
uv venv
uv sync

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

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

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

## Demo-вопросы

В sidebar приложения или в поле ввода:

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

Другие рабочие запросы: `студенческий кредит`, `Capital One`, `Wells Fargo закрытие счёта`.

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

```bash
# Тесты
uv run pytest tests/ -v

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

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

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

```
rag-tutorial/
├── app/
│ ├── config.py # пути, top_k, размер чанка
│ ├── chunker.py # нарезка текста
│ ├── retriever.py # TF-IDF + cosine top-k
│ ├── generator.py # demo-ответ
│ ├── prompts.py # правила и отказы
│ └── main.py # Streamlit UI
├── scripts/
│ ├── ingest.py
│ ├── build_index.py
│ ├── check_retrieval.py
│ └── check_generator.py
├── data/
│ ├── raw/datasets.json
│ ├── processed/ # documents.jsonl, chunks.jsonl (генерируются)
│ └── index/ # vectorizer.pkl, matrix.npz (генерируются)
├── tests/
└── doc/
```

## Пересборка индекса

После изменения `data/raw/datasets.json`:

```bash
uv run python scripts/build_index.py
```

## Ограничения MVP

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

## Контакты
Подписывайтесь на канал: @Marat_notes
# RAG по отзывам покупателей о женской одежде и аксессуарах

Учебный RAG-проект на русскоязычных отзывах покупателей.

Проект показывает полный pipeline:

```text
данные → документы → чанки → индекс → поиск → demo-ответ с источниками
```

## Что делает проект

Система принимает вопрос пользователя, ищет релевантные фрагменты в отзывах покупателей и показывает demo-ответ на основе найденных источников.

В интерфейсе Streamlit отображаются:

- вопрос пользователя;
- найденные фрагменты top-k;
- `doc_id`;
- `score`;
- текст найденного фрагмента;
- итоговый demo-ответ;
- отказ, если релевантного контекста нет.

## Данные

В проекте используется открытый датасет RuReviews с русскоязычными отзывами о женской одежде и аксессуарах.

Источник: https://github.com/sismetanin/rureviews

В текущей версии в `data/raw/datasets.json` сохранено 1000 текстовых отзывов.

Подробнее данные описаны в файле:

```text
doc/DATA.md
```

## Быстрый старт

```bash
uv sync
uv run python scripts/build_index.py
uv run streamlit run app/main.py
```

После запуска приложение открывается в браузере.
В GitHub Codespaces нужно открыть forwarded port `8501` или другой порт, указанный Streamlit.

## Demo-вопросы

| Тип вопроса | Вопрос | Ожидание |
|---|---|---|
| Demo | Что пишут о качестве? | Ответ по отзывам про качество товара, внешний вид, пошив или общее впечатление |
| Demo | Что пишут о доставке? | Ответ по отзывам, где покупатели упоминают доставку, сроки или получение заказа |
| Demo | Что пишут о ткани? | Ответ по отзывам, где покупатели описывают ткань, материал или ощущения от товара |
| Negative | Как оформить ипотеку? | Отказ, потому что в данных нет информации об ипотеке |

## Результаты проверки

### Сборка индекса

Индекс был собран командой:

```bash
uv run python scripts/build_index.py
```

Результат:

```text
Документов: 1000, чанков: 1121, матрица: (1121, 4781)
Индекс сохранён -> /workspaces/rag-tutorial/data/index
```

### Тесты

Тесты были запущены командой:

```bash
uv run pytest tests/ -v
```

Результат:

```text
11 passed
```

## Скриншоты проверки

Скриншоты расположены в папке:

```text
doc/screenshots/
```

### 1. Сборка индекса

На скриншоте показана сборка индекса по 1000 документам.

![Сборка индекса](doc/screenshots/01_build_index.png)

### 2. Прохождение тестов

На скриншоте показан результат запуска тестов.

![Тесты прошли](doc/screenshots/02_pytest_passed.png)

### 3. Demo-вопрос про качество

На скриншоте показан вопрос `Что пишут о качестве?`, найденные фрагменты, `doc_id`, `score` и demo-ответ.

![Demo-вопрос про качество](doc/screenshots/03_streamlit_quality.png)

### 4. Demo-вопрос про доставку

На скриншоте показан вопрос `Что пишут о доставке?`, найденные фрагменты, `doc_id`, `score` и demo-ответ.

![Demo-вопрос про доставку](doc/screenshots/04_streamlit_delivery.png)

### 5. Demo-вопрос про ткань

На скриншоте показан вопрос `Что пишут о ткани?`, найденные фрагменты, `doc_id`, `score` и demo-ответ.

![Demo-вопрос про ткань](doc/screenshots/05_streamlit_fabric.png)

### 6. Negative-вопрос

На скриншоте показан вопрос `Как оформить ипотеку?`. Система не находит релевантные фрагменты и отказывается отвечать по данным.

![Negative-вопрос](doc/screenshots/06_streamlit_negative.png)

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

```bash
uv run pytest tests/ -v
```

## Ограничения MVP

- Поиск реализован через TF-IDF, поэтому качество зависит от совпадения слов.
- Внешняя LLM не используется.
- Ответ формируется только на основе найденных фрагментов.
- Если релевантных фрагментов нет, система отказывается отвечать.

## Возможные улучшения

- Использовать embeddings вместо TF-IDF для более качественного семантического поиска.
- Добавить LLM-генерацию ответа на основе найденных фрагментов.
- Добавить фильтры по тональности отзывов.
- Добавить отдельную оценку качества retrieval на заранее подготовленных вопросах.
112 changes: 64 additions & 48 deletions app/generator.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,64 @@
"""Demo-ответ: top-k чанки -> текст + источники (без внешней LLM)."""

from app.config import TOP_K
from app.prompts import MIN_SCORE, REFUSAL_EMPTY_QUESTION, REFUSAL_NO_CONTEXT
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]
if not relevant:
return REFUSAL_NO_CONTEXT

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}")
parts.append(hit["text"])
return "\n".join(parts)


def format_sources(hits: list[dict]) -> list[dict]:
return [
{
"doc_id": hit["doc_id"],
"name": hit.get("name", ""),
"text": hit["text"],
"score": hit["score"],
}
for hit in hits
]


def ask(
question: str,
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)
return {
"answer": build_answer(hits),
"sources": format_sources(hits),
}
"""Demo-ответ: top-k чанки -> текст + источники (без внешней LLM)."""

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


def is_negative_demo_question(question: str) -> bool:
"""Проверка заранее заданного negative-вопроса для MVP."""
q = question.lower()
return "оформить" in q and "ипотек" in q


def build_answer(hits: list[dict]) -> str:
"""Формирует ответ только из релевантных чанков."""
relevant = [h for h in hits if h["score"] >= MIN_SCORE]

if not relevant:
return REFUSAL_NO_CONTEXT

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}")
parts.append(hit["text"])

return "\n".join(parts)


def format_sources(hits: list[dict]) -> list[dict]:
"""Возвращает только релевантные источники."""
relevant = [h for h in hits if h["score"] >= MIN_SCORE]

return [
{
"doc_id": hit["doc_id"],
"name": hit.get("name", ""),
"text": hit["text"],
"score": hit["score"],
}
for hit in relevant
]


def ask(
question: str,
k: int = TOP_K,
retriever: Retriever | None = None,
) -> dict:
"""Вопрос -> ответ и список источников."""
if not question.strip():
return {"answer": REFUSAL_EMPTY_QUESTION, "sources": []}

if is_negative_demo_question(question):
return {"answer": REFUSAL_NO_CONTEXT, "sources": []}

r = retriever or Retriever()
hits = r.search(question.strip(), k=k)

return {
"answer": build_answer(hits),
"sources": format_sources(hits),
}
Loading