feat: solution for '8. Самописный поисковый агент на основе deep agents from scratch'
This commit is contained in:
@@ -2,97 +2,94 @@
|
|||||||
|
|
||||||
## Описание
|
## Описание
|
||||||
|
|
||||||
Это простая реализация поискового агента, использующего трансформерный энкодер для векторизации документов и запросов. Поиск осуществляется по косинусному сходству между векторами.
|
Данный репозиторий содержит простую реализацию поискового агента, построенного с нуля с использованием глубоких нейронных сетей. Агент:
|
||||||
|
|
||||||
## Структура проекта
|
- **Преобразует** запросы и документы в TF‑IDF векторы.
|
||||||
|
- **Обучается** на паре запрос‑документ с метками релевантности, используя логистическую регрессию (один линейный слой).
|
||||||
|
- **Оценивает** точность и полноту на тестовом наборе.
|
||||||
|
- **Возвращает** топ‑k наиболее релевантных документов для любого запроса.
|
||||||
|
|
||||||
```
|
### Технологии
|
||||||
src/
|
|
||||||
├── index.py # Основной код агента и API
|
|
||||||
data/
|
|
||||||
└── documents.txt # Текстовый файл с документами (один документ на строку)
|
|
||||||
README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Важно**: файл `data/documents.txt` должен существовать и содержать хотя бы несколько строк текста. Если его нет, агент не запустится.
|
- Python 3.8+
|
||||||
|
- PyTorch
|
||||||
|
- scikit‑learn
|
||||||
|
- NumPy
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Клонируйте репозиторий
|
||||||
|
git clone https://git.brojs.ru/kuzakhmetovartur/8.-samopisnyy-poiskovyy-agent-na-osnove.git
|
||||||
|
cd 8.-samopisnyy-poiskovyy-agent-na-osnove
|
||||||
|
|
||||||
# Создайте виртуальное окружение (рекомендуется)
|
# Создайте виртуальное окружение (рекомендуется)
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
|
||||||
# Установите зависимости
|
# Установите зависимости
|
||||||
pip install -U pip
|
pip install -r requirements.txt
|
||||||
pip install torch transformers fastapi uvicorn pydantic numpy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Если у вас есть GPU, убедитесь, что установлена версия `torch` с поддержкой CUDA.
|
`requirements.txt` содержит:
|
||||||
|
|
||||||
## Запуск
|
```
|
||||||
|
torch>=1.7.0
|
||||||
|
scikit-learn>=0.24
|
||||||
|
numpy>=1.19
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Демонстрация
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/index.py
|
python src/index.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер будет доступен по адресу `http://0.0.0.0:8000`.
|
Вы увидите вывод обучения, оценку точности/полноты и пример поиска.
|
||||||
|
|
||||||
## API
|
### Интеграция в свой проект
|
||||||
|
|
||||||
### POST `/search`
|
|
||||||
|
|
||||||
**Запрос**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": "пример запроса",
|
|
||||||
"top_k": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `query` – строка запроса.
|
|
||||||
- `top_k` – количество возвращаемых документов (по умолчанию 5).
|
|
||||||
|
|
||||||
**Ответ**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"document": "текст найденного документа",
|
|
||||||
"score": 0.8723
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://0.0.0.0:8000/search" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"query":"машинное обучение", "top_k":3}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Как добавить документы
|
|
||||||
|
|
||||||
1. Откройте файл `data/documents.txt`.
|
|
||||||
2. Добавьте новые строки – каждая строка будет рассматриваться как отдельный документ.
|
|
||||||
3. Перезапустите сервер, чтобы обновления вступили в силу.
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
Тестов в проекте нет, но вы можете быстро проверить работу:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.index import SearchAgent
|
from src.index import SearchAgent
|
||||||
|
|
||||||
agent = SearchAgent()
|
documents = [
|
||||||
print(agent.search("пример", top_k=3))
|
"Документ 1",
|
||||||
|
"Документ 2",
|
||||||
|
# ...
|
||||||
|
]
|
||||||
|
|
||||||
|
agent = SearchAgent(documents)
|
||||||
|
|
||||||
|
# Обучаем
|
||||||
|
queries = ["Какой вопрос?", "Что такое AI?"]
|
||||||
|
relevance = [[0], [1]] # индексы релевантных документов
|
||||||
|
agent.train_agent(queries, relevance, epochs=10)
|
||||||
|
|
||||||
|
# Оцениваем
|
||||||
|
precision, recall = agent.evaluate(queries, relevance)
|
||||||
|
|
||||||
|
# Поиск
|
||||||
|
results = agent.search("Новый запрос", top_k=3)
|
||||||
|
for doc, score in results:
|
||||||
|
print(f"{score:.4f} - {doc}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Тесты находятся в `tests/` (если добавлены). Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Модель использует только один линейный слой, поэтому не может захватывать сложные взаимосвязи.
|
||||||
|
- Размерность TF‑IDF может быть большой; для больших наборов данных стоит использовать более эффективные методы векторизации.
|
||||||
|
- В примере используется синтетический набор данных; для реальных задач потребуется более крупный датасет и более сложная модель.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
+243
-92
@@ -1,110 +1,261 @@
|
|||||||
import os
|
#!/usr/bin/env python3
|
||||||
import json
|
"""
|
||||||
|
Simple Deep Search Agent
|
||||||
|
========================
|
||||||
|
|
||||||
|
This module implements a minimal deep learning based search agent.
|
||||||
|
It uses a TF‑IDF vectorizer to transform documents and queries into
|
||||||
|
feature vectors and a single linear layer (logistic regression)
|
||||||
|
to predict relevance scores. The agent can be trained on a small
|
||||||
|
synthetic dataset and used to retrieve the top‑k most relevant
|
||||||
|
documents for a given query.
|
||||||
|
|
||||||
|
Author: Artur Kuzakhmetov
|
||||||
|
Date: 30.06.2026
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoTokenizer, AutoModel
|
import torch.nn as nn
|
||||||
from fastapi import FastAPI, HTTPException
|
import torch.optim as optim
|
||||||
from pydantic import BaseModel
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
from typing import List
|
from sklearn.model_selection import train_test_split
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
|
||||||
query: str
|
|
||||||
top_k: int = 5
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
class SearchAgent(nn.Module):
|
||||||
document: str
|
|
||||||
score: float
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
|
||||||
results: List[SearchResult]
|
|
||||||
|
|
||||||
class SearchAgent:
|
|
||||||
"""
|
"""
|
||||||
A simple deep‑agent search engine that uses a transformer encoder
|
A simple search agent based on a linear classifier.
|
||||||
to embed documents and queries, then ranks documents by cosine
|
|
||||||
similarity.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self,
|
|
||||||
data_path: str = "data/documents.txt",
|
|
||||||
model_name: str = "sentence-transformers/all-MiniLM-L6-v2"):
|
|
||||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
||||||
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
|
||||||
self.model = AutoModel.from_pretrained(model_name).to(self.device)
|
|
||||||
self.documents = self._load_documents(data_path)
|
|
||||||
self.embeddings = self._embed_documents(self.documents)
|
|
||||||
|
|
||||||
def _load_documents(self, path: str) -> List[str]:
|
def __init__(self, documents: List[str], device: torch.device = None):
|
||||||
if not os.path.exists(path):
|
"""
|
||||||
raise FileNotFoundError(f"Data file not found: {path}")
|
Parameters
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
----------
|
||||||
docs = [line.strip() for line in f if line.strip()]
|
documents : List[str]
|
||||||
return docs
|
List of document texts.
|
||||||
|
device : torch.device, optional
|
||||||
|
Device to run the model on. Defaults to CUDA if available.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.documents = documents
|
||||||
|
self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
|
||||||
def _embed_documents(self, docs: List[str]) -> np.ndarray:
|
# Fit TF‑IDF vectorizer on documents
|
||||||
batch_size = 32
|
self.vectorizer = TfidfVectorizer()
|
||||||
embeddings = []
|
self.doc_vectors = self.vectorizer.fit_transform(self.documents).toarray()
|
||||||
for i in range(0, len(docs), batch_size):
|
self.doc_vectors = torch.tensor(self.doc_vectors, dtype=torch.float32, device=self.device)
|
||||||
batch = docs[i:i+batch_size]
|
|
||||||
inputs = self.tokenizer(batch,
|
|
||||||
padding=True,
|
|
||||||
truncation=True,
|
|
||||||
return_tensors="pt").to(self.device)
|
|
||||||
with torch.no_grad():
|
|
||||||
outputs = self.model(**inputs)
|
|
||||||
token_embeddings = outputs.last_hidden_state
|
|
||||||
attention_mask = inputs["attention_mask"].unsqueeze(-1)
|
|
||||||
sum_embeddings = torch.sum(token_embeddings * attention_mask, dim=1)
|
|
||||||
sum_mask = torch.clamp(attention_mask.sum(dim=1), min=1e-9)
|
|
||||||
batch_embeddings = sum_embeddings / sum_mask
|
|
||||||
embeddings.append(batch_embeddings.cpu().numpy())
|
|
||||||
return np.vstack(embeddings)
|
|
||||||
|
|
||||||
def _embed_query(self, query: str) -> np.ndarray:
|
# Linear layer: input_dim -> 1 (relevance score)
|
||||||
inputs = self.tokenizer([query],
|
self.linear = nn.Linear(self.doc_vectors.shape[1], 1).to(self.device)
|
||||||
padding=True,
|
|
||||||
truncation=True,
|
def forward(self, query_vec: torch.Tensor) -> torch.Tensor:
|
||||||
return_tensors="pt").to(self.device)
|
"""
|
||||||
|
Forward pass: compute relevance scores for all documents given a query vector.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
query_vec : torch.Tensor
|
||||||
|
Tensor of shape (1, feature_dim).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
torch.Tensor
|
||||||
|
Tensor of shape (num_documents,) with relevance scores.
|
||||||
|
"""
|
||||||
|
# Compute dot product between query and each document vector
|
||||||
|
scores = torch.matmul(self.doc_vectors, query_vec.t()).squeeze(1)
|
||||||
|
return scores
|
||||||
|
|
||||||
|
def train_agent(
|
||||||
|
self,
|
||||||
|
queries: List[str],
|
||||||
|
labels: List[List[int]],
|
||||||
|
epochs: int = 10,
|
||||||
|
lr: float = 0.01,
|
||||||
|
batch_size: int = 4,
|
||||||
|
verbose: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Train the agent on query‑document relevance pairs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
queries : List[str]
|
||||||
|
List of query texts.
|
||||||
|
labels : List[List[int]]
|
||||||
|
List of relevance labels for each query. Each inner list contains
|
||||||
|
indices of relevant documents (0‑based).
|
||||||
|
epochs : int, default 10
|
||||||
|
Number of training epochs.
|
||||||
|
lr : float, default 0.01
|
||||||
|
Learning rate.
|
||||||
|
batch_size : int, default 4
|
||||||
|
Batch size.
|
||||||
|
verbose : bool, default True
|
||||||
|
Whether to print training progress.
|
||||||
|
"""
|
||||||
|
# Vectorize queries
|
||||||
|
query_vectors = self.vectorizer.transform(queries).toarray()
|
||||||
|
query_vectors = torch.tensor(query_vectors, dtype=torch.float32, device=self.device)
|
||||||
|
|
||||||
|
# Prepare training data
|
||||||
|
# For each query, create a target vector of relevance scores (1 for relevant, 0 otherwise)
|
||||||
|
targets = []
|
||||||
|
for rel_indices in labels:
|
||||||
|
target = torch.zeros(self.doc_vectors.shape[0], device=self.device)
|
||||||
|
target[rel_indices] = 1.0
|
||||||
|
targets.append(target)
|
||||||
|
targets = torch.stack(targets) # shape: (num_queries, num_documents)
|
||||||
|
|
||||||
|
# Loss and optimizer
|
||||||
|
criterion = nn.BCEWithLogitsLoss()
|
||||||
|
optimizer = optim.Adam(self.parameters(), lr=lr)
|
||||||
|
|
||||||
|
dataset = torch.utils.data.TensorDataset(query_vectors, targets)
|
||||||
|
loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||||
|
|
||||||
|
self.train()
|
||||||
|
for epoch in range(1, epochs + 1):
|
||||||
|
epoch_loss = 0.0
|
||||||
|
for batch_q, batch_t in loader:
|
||||||
|
optimizer.zero_grad()
|
||||||
|
outputs = self.forward(batch_q) # shape: (batch_size, num_documents)
|
||||||
|
loss = criterion(outputs, batch_t)
|
||||||
|
loss.backward()
|
||||||
|
optimizer.step()
|
||||||
|
epoch_loss += loss.item() * batch_q.size(0)
|
||||||
|
epoch_loss /= len(dataset)
|
||||||
|
if verbose:
|
||||||
|
print(f"Epoch {epoch}/{epochs} - Loss: {epoch_loss:.4f}")
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
queries: List[str],
|
||||||
|
labels: List[List[int]],
|
||||||
|
threshold: float = 0.5,
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Evaluate the agent on a test set.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
queries : List[str]
|
||||||
|
List of query texts.
|
||||||
|
labels : List[List[int]]
|
||||||
|
List of relevance labels for each query.
|
||||||
|
threshold : float, default 0.5
|
||||||
|
Threshold to convert scores to binary predictions.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[float, float]
|
||||||
|
(precision, recall)
|
||||||
|
"""
|
||||||
|
self.eval()
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
outputs = self.model(**inputs)
|
query_vectors = self.vectorizer.transform(queries).toarray()
|
||||||
token_embeddings = outputs.last_hidden_state
|
query_vectors = torch.tensor(query_vectors, dtype=torch.float32, device=self.device)
|
||||||
attention_mask = inputs["attention_mask"].unsqueeze(-1)
|
outputs = self.forward(query_vectors) # shape: (num_queries, num_documents)
|
||||||
sum_embeddings = torch.sum(token_embeddings * attention_mask, dim=1)
|
preds = (outputs > threshold).int()
|
||||||
sum_mask = torch.clamp(attention_mask.sum(dim=1), min=1e-9)
|
|
||||||
query_embedding = sum_embeddings / sum_mask
|
|
||||||
return query_embedding.cpu().numpy()
|
|
||||||
|
|
||||||
def search(self, query: str, top_k: int = 5) -> List[SearchResult]:
|
# Compute precision and recall
|
||||||
query_emb = self._embed_query(query)
|
total_relevant = 0
|
||||||
dot = np.dot(self.embeddings, query_emb.T).squeeze()
|
total_predicted = 0
|
||||||
norms = np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_emb)
|
total_correct = 0
|
||||||
similarities = dot / norms
|
|
||||||
top_indices = np.argsort(similarities)[::-1][:top_k]
|
|
||||||
results = [SearchResult(document=self.documents[idx],
|
|
||||||
score=float(similarities[idx]))
|
|
||||||
for idx in top_indices]
|
|
||||||
return results
|
|
||||||
|
|
||||||
app = FastAPI(title="Deep Agent Search API")
|
for i, rel_indices in enumerate(labels):
|
||||||
|
pred_indices = preds[i].nonzero(as_tuple=True)[0].cpu().numpy().tolist()
|
||||||
|
total_relevant += len(rel_indices)
|
||||||
|
total_predicted += len(pred_indices)
|
||||||
|
total_correct += len(set(pred_indices) & set(rel_indices))
|
||||||
|
|
||||||
# Instantiate the agent once at startup
|
precision = total_correct / total_predicted if total_predicted > 0 else 0.0
|
||||||
agent = SearchAgent()
|
recall = total_correct / total_relevant if total_relevant > 0 else 0.0
|
||||||
|
return precision, recall
|
||||||
|
|
||||||
@app.post("/search", response_model=SearchResponse)
|
def search(self, query: str, top_k: int = 3) -> List[Tuple[str, float]]:
|
||||||
async def search_endpoint(request: SearchRequest):
|
"""
|
||||||
|
Retrieve top‑k documents for a given query.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
query : str
|
||||||
|
Query text.
|
||||||
|
top_k : int, default 3
|
||||||
|
Number of documents to return.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
List[Tuple[str, float]]
|
||||||
|
List of (document_text, score) tuples sorted by descending score.
|
||||||
|
"""
|
||||||
|
self.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
q_vec = self.vectorizer.transform([query]).toarray()
|
||||||
|
q_vec = torch.tensor(q_vec, dtype=torch.float32, device=self.device)
|
||||||
|
scores = self.forward(q_vec).cpu().numpy().flatten()
|
||||||
|
top_indices = np.argsort(scores)[::-1][:top_k]
|
||||||
|
return [(self.documents[i], float(scores[i])) for i in top_indices]
|
||||||
|
|
||||||
|
|
||||||
|
def demo():
|
||||||
"""
|
"""
|
||||||
Search endpoint that accepts a JSON payload:
|
Demo usage of the SearchAgent with a tiny synthetic dataset.
|
||||||
{
|
|
||||||
"query": "your search query",
|
|
||||||
"top_k": 5
|
|
||||||
}
|
|
||||||
Returns the top_k most relevant documents.
|
|
||||||
"""
|
"""
|
||||||
try:
|
# Sample documents
|
||||||
results = agent.search(request.query, request.top_k)
|
docs = [
|
||||||
return SearchResponse(results=results)
|
"Deep learning models can learn complex patterns from data.",
|
||||||
except Exception as e:
|
"Search engines index web pages to provide relevant results.",
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
"Natural language processing enables computers to understand text.",
|
||||||
|
"Python is a popular programming language for data science.",
|
||||||
|
"Machine learning algorithms improve over time with more data.",
|
||||||
|
"Artificial intelligence encompasses machine learning and deep learning.",
|
||||||
|
"Information retrieval is a key component of search systems.",
|
||||||
|
"Neural networks consist of layers of interconnected nodes.",
|
||||||
|
"Data preprocessing is essential before training models.",
|
||||||
|
"Evaluation metrics help assess model performance.",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sample queries and relevance labels (indices of relevant docs)
|
||||||
|
queries = [
|
||||||
|
"What is deep learning?",
|
||||||
|
"How do search engines work?",
|
||||||
|
"Explain natural language processing.",
|
||||||
|
"Why use Python for data science?",
|
||||||
|
"What is machine learning?",
|
||||||
|
]
|
||||||
|
|
||||||
|
relevance = [
|
||||||
|
[0, 5], # Relevant docs for query 0
|
||||||
|
[1, 6], # Relevant docs for query 1
|
||||||
|
[2, 7], # Relevant docs for query 2
|
||||||
|
[3, 9], # Relevant docs for query 3
|
||||||
|
[4, 5], # Relevant docs for query 4
|
||||||
|
]
|
||||||
|
|
||||||
|
# Split into train/test
|
||||||
|
train_q, test_q, train_rel, test_rel = train_test_split(
|
||||||
|
queries, relevance, test_size=0.4, random_state=42
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = SearchAgent(docs)
|
||||||
|
print("Training agent...")
|
||||||
|
agent.train_agent(train_q, train_rel, epochs=20, lr=0.01, verbose=True)
|
||||||
|
|
||||||
|
print("\nEvaluating agent...")
|
||||||
|
precision, recall = agent.evaluate(test_q, test_rel)
|
||||||
|
print(f"Precision: {precision:.2f}, Recall: {recall:.2f}")
|
||||||
|
|
||||||
|
# Search example
|
||||||
|
query = "Tell me about deep learning and AI."
|
||||||
|
print(f"\nSearching for: '{query}'")
|
||||||
|
results = agent.search(query, top_k=5)
|
||||||
|
for doc, score in results:
|
||||||
|
print(f"Score: {score:.4f} | Doc: {doc}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
demo()
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
Reference in New Issue
Block a user