forked from zovos/vk_hackathon
Compare commits
4 commits
9db1d12192
...
97c2e1710c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c2e1710c | ||
|
|
3ea1cd3066 | ||
| 142580cd96 | |||
|
|
a291d33ffa |
3 changed files with 190 additions and 7 deletions
64
doc/todo_people.md
Normal file
64
doc/todo_people.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
Для того чтобы раскидать задачи между тремя людьми, я распределю их по сложности и приоритету.
|
||||
|
||||
### Человек 1 (Основной фокус на поиске):
|
||||
|
||||
#### `search/main.py`
|
||||
|
||||
* **P0**: Переключить основной query на `question.search_text` с fallback на `question.text`
|
||||
* **P0**: Подключить `question.variants` как дополнительные query-варианты
|
||||
* **P0**: Подключить `question.hyde` как дополнительные dense-запросы
|
||||
* **P0**: Подключить `question.keywords` как основу для sparse-запросов
|
||||
* **P0**: Перестать терять retrieval-кандидатов после rerank
|
||||
* **P0**: Дедуплицировать `message_ids` перед ответом
|
||||
* **P0**: Ограничить финальную выдачу до `top-50`
|
||||
* **P0**: Агрегировать score по `message_id`
|
||||
* **P2**: Использовать `entities.people` и `entities.emails` для boost или фильтрации
|
||||
* **P2**: Использовать `entities.documents`, `entities.names`, `entities.links` для lexical boost
|
||||
* **P2**: Использовать `date_range` для фильтрации по `metadata.start` и `metadata.end`
|
||||
* **P2**: Использовать `contains_quote` и `contains_forward` как сигналы ранжирования
|
||||
* **P2**: Добавить multi-query fusion в `Qdrant`
|
||||
* **P2**: Подобрать `prefetch`, `retrieve_k`, `rerank_limit`
|
||||
* **P3**: Добавить retry и timeout политику для dense/rerank HTTP вызовов
|
||||
|
||||
---
|
||||
|
||||
### Человек 2 (Основной фокус на индексации и разметке):
|
||||
|
||||
#### `index/main.py`
|
||||
|
||||
* **P1**: Перейти с символьного chunking на chunking по сообщениям
|
||||
* **P1**: Учитывать time gap при сборке чанков
|
||||
* **P1**: Маркировать в тексте `quote`, `forward`, автора сообщения и автора цитаты
|
||||
* **P1**: Развести `page_content`, `dense_content`, `sparse_content`
|
||||
* **P1**: Материализовать `sender_id` и `mentions` в индексируемый текст
|
||||
* **P1**: Разбирать `file_snippets` и вытаскивать имя файла, mime и url
|
||||
* **P1**: Разбирать `member_event` и превращать его в индексируемый текст
|
||||
|
||||
#### `index/requirements.txt` и/или `search/requirements.txt`
|
||||
|
||||
* **P4**: Добавить `razdel`
|
||||
* **P4**: Добавить `pymorphy3`
|
||||
* **P4**: Добавить `rapidfuzz`
|
||||
|
||||
---
|
||||
|
||||
### Человек 3 (Основной фокус на Docker и зависимостях):
|
||||
|
||||
#### `docker-compose.yml`
|
||||
|
||||
* **P3**: Привести локальный `docker-compose.yml` к схеме с `API_KEY`
|
||||
* **P3**: Добавить `--platform linux/amd64` в сборку образов
|
||||
|
||||
#### `doc/` (новый файл с регрессионными вопросами)
|
||||
|
||||
* **P3**: Зафиксировать набор локальных тестовых вопросов для проверки регрессий
|
||||
|
||||
#### `search/requirements.txt`
|
||||
|
||||
* **P4**: Добавить `python-dateutil` или `dateparser`
|
||||
* **P4**: Добавить `tenacity`
|
||||
|
||||
---
|
||||
|
||||
Таким образом, задачи равномерно распределены по 3 участникам с учётом сложности и области фокуса.
|
||||
|
||||
|
|
@ -60,3 +60,4 @@ services:
|
|||
OPEN_API_PASSWORD: ${OPEN_API_PASSWORD:?set OPEN_API_PASSWORD before docker compose up}
|
||||
ports:
|
||||
- "8002:8000"
|
||||
|
||||
|
|
|
|||
132
search/main.py
132
search/main.py
|
|
@ -270,6 +270,111 @@ async def qdrant_search(
|
|||
return response.points
|
||||
|
||||
|
||||
async def qdrant_search_dense_only(
|
||||
client: AsyncQdrantClient,
|
||||
dense_vector: list[float],
|
||||
question_data: Question,
|
||||
) -> Any | None:
|
||||
must_conditions: []
|
||||
|
||||
if question_data.date_range:
|
||||
must_conditions.append(
|
||||
models.FieldCondition(
|
||||
key="metadata.start",
|
||||
range=models.Range(
|
||||
gte=question_data.date_range.from_,
|
||||
lte=question_data.date_range.to_,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if question_data.asker:
|
||||
must_conditions.append(
|
||||
models.FieldCondition(
|
||||
key="metadata.participants",
|
||||
match=models.MatchValue(value=question_data.asker),
|
||||
)
|
||||
)
|
||||
|
||||
search_filter = models.Filter(must=must_conditions) if must_conditions else None
|
||||
|
||||
response = await client.query_points(
|
||||
collection_name=QDRANT_COLLECTION_NAME,
|
||||
prefetch=[
|
||||
models.Prefetch(
|
||||
query=dense_vector,
|
||||
using=QDRANT_DENSE_VECTOR_NAME,
|
||||
limit=DENSE_PREFETCH_K,
|
||||
filter=search_filter,
|
||||
),
|
||||
],
|
||||
query=models.FusionQuery(fusion=models.Fusion.RRF),
|
||||
limit=RETRIEVE_K,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
if not response.points:
|
||||
return None
|
||||
|
||||
return response.points
|
||||
|
||||
|
||||
def collect_query_variants(question: Question) -> list[str]:
|
||||
variants: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_query(text: str | None) -> None:
|
||||
if text is None:
|
||||
return
|
||||
normalized = text.strip()
|
||||
if not normalized:
|
||||
return
|
||||
if normalized in seen:
|
||||
return
|
||||
seen.add(normalized)
|
||||
variants.append(normalized)
|
||||
|
||||
add_query(question.search_text)
|
||||
add_query(question.text)
|
||||
|
||||
for variant in question.variants or []:
|
||||
add_query(variant)
|
||||
|
||||
return variants
|
||||
|
||||
|
||||
def collect_hyde_queries(question: Question, base_queries: list[str]) -> list[str]:
|
||||
hyde_queries: list[str] = []
|
||||
seen: set[str] = set(base_queries)
|
||||
|
||||
for hyde_query in question.hyde or []:
|
||||
normalized = hyde_query.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
hyde_queries.append(normalized)
|
||||
|
||||
return hyde_queries
|
||||
|
||||
|
||||
def deduplicate_points(points: list[Any]) -> list[Any]:
|
||||
unique_points: list[Any] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for point in points:
|
||||
point_id = str(getattr(point, "id", ""))
|
||||
if not point_id:
|
||||
continue
|
||||
if point_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(point_id)
|
||||
unique_points.append(point)
|
||||
|
||||
return unique_points
|
||||
|
||||
|
||||
def extract_message_ids(point: Any) -> list[str]:
|
||||
payload = point.payload or {}
|
||||
metadata = payload.get("metadata") or {}
|
||||
|
|
@ -334,18 +439,31 @@ async def health() -> dict[str, str]:
|
|||
|
||||
@app.post("/search", response_model=SearchAPIResponse)
|
||||
async def search(payload: SearchAPIRequest) -> SearchAPIResponse:
|
||||
query = payload.question.text.strip()
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="question.text is required")
|
||||
queries = collect_query_variants(payload.question)
|
||||
if not queries:
|
||||
raise HTTPException(status_code=400, detail="question.search_text or question.text is required")
|
||||
|
||||
hyde_queries = collect_hyde_queries(payload.question, queries)
|
||||
query = queries[0]
|
||||
client: httpx.AsyncClient = app.state.http
|
||||
qdrant: AsyncQdrantClient = app.state.qdrant
|
||||
|
||||
dense_vector = await embed_dense(client, query)
|
||||
sparse_vector = await embed_sparse(query)
|
||||
best_points = await qdrant_search(qdrant, dense_vector, sparse_vector, payload.question)
|
||||
all_points: list[Any] = []
|
||||
for query_variant in queries:
|
||||
dense_vector = await embed_dense(client, query_variant)
|
||||
sparse_vector = await embed_sparse(query_variant)
|
||||
points = await qdrant_search(qdrant, dense_vector, sparse_vector, payload.question)
|
||||
if points:
|
||||
all_points.extend(list(points))
|
||||
|
||||
if best_points is None:
|
||||
for hyde_query in hyde_queries:
|
||||
hyde_dense_vector = await embed_dense(client, hyde_query)
|
||||
hyde_points = await qdrant_search_dense_only(qdrant, hyde_dense_vector, payload.question)
|
||||
if hyde_points:
|
||||
all_points.extend(list(hyde_points))
|
||||
|
||||
best_points = deduplicate_points(all_points)
|
||||
if not best_points:
|
||||
return SearchAPIResponse(results=[])
|
||||
|
||||
best_points = await rerank_points(client, query, list(best_points))
|
||||
|
|
|
|||
Loading…
Reference in a new issue