spike: extract_atoms.py — bge-m3 + Qdrant dedup (387 atoms, 17 dupes found)
This commit is contained in:
1 parent
ad180cc518
commit
ecd3c2e7b3
2 files changed
+242
No files matched your search
@@ -0,0 +1,36 @@
|
|||||||
|
# spike/extract_atoms.py — выводы
|
||||||
|
|
||||||
|
## Результаты спейка (2026-09-14)
|
||||||
|
|
||||||
|
**Тест:** 2 статьи (glvrd-research.md, promotion-plan-unikor-v2.md) → 387 атомов.
|
||||||
|
|
||||||
|
- **Эмбеддинг bge-m3:** ~19ms/atom (батчами по 5)
|
||||||
|
- **Qdrant:** коллекция `content_atoms` (dim=1024, Cosine)
|
||||||
|
- **Дедуп:** 17 дублей найдено (порог 0.85)
|
||||||
|
|
||||||
|
## Ключевые находки
|
||||||
|
|
||||||
|
### Qdrant source build (1.18.2) — баг схемы
|
||||||
|
- `PUT /collections/{name}` и `POST /collections/{name}` создают коллекцию с `vectors: {}`
|
||||||
|
- Все способы создания (vector, vector_params, vector_params_map) дают пустую схему
|
||||||
|
- **Решение:** использовать Python-клиент `qdrant-client` (1.19.0) — он правильно формирует запросы
|
||||||
|
- `create_collection(collection_name=..., vectors_config=VectorParams(size=1024, distance=Distance.COSINE))`
|
||||||
|
|
||||||
|
### Qdrant API v1 (локальный бинарник)
|
||||||
|
- `PUT /collections/{name}` — создать (но с пустой схемой)
|
||||||
|
- `POST /collections/{name}/points` — upsert (нужен top-level `ids`)
|
||||||
|
- `POST /collections/{name}/points/search` — поиск (нужен `vector`)
|
||||||
|
- Point id = UUID или unsigned int, не строка
|
||||||
|
- `search` требует `vector`, `query_points` — альтернатива
|
||||||
|
|
||||||
|
### bge-m3
|
||||||
|
- dim=1024, ~19ms/atom (локально)
|
||||||
|
- Правильно работает для русского SEO-текста
|
||||||
|
- Гибридный (dense+sparse) — но в спейке только dense
|
||||||
|
|
||||||
|
## Выводы
|
||||||
|
|
||||||
|
1. **bge-m3 + Qdrant работают** — дедуп между доменами ловится (17 дублей)
|
||||||
|
2. **Qdrant source build** — использовать Python-клиент, не raw HTTP
|
||||||
|
3. **Масштаб:** 387 атомов за 7.4с → ~50 доменов × 200 атомов = ~10000 атомов за ~3мин (приемлемо)
|
||||||
|
4. **Следующий шаг:** MVP stage 1 (`extract` → `atom_versions` → web-канал)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SPIKE: atom extraction + bge-m3 embeddings + Qdrant dedup.
|
||||||
|
|
||||||
|
Разбирает Markdown-статью на типизированные атомы, эмбедирует bge-m3,
|
||||||
|
сохраняет в Qdrant и проверяет дедупликацию между статьями.
|
||||||
|
|
||||||
|
bge-m3 — единственная embed-модель (dim=1024). Не переключаться на nomic.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
python3 spike/extract_atoms.py --article file.md [--article file2.md] --project rabota-ufa
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.models import Distance, VectorParams, PointStruct
|
||||||
|
|
||||||
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||||
|
EMBED_MODEL = "bge-m3"
|
||||||
|
COLLECTION = "content_atoms"
|
||||||
|
DIM = 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Atom:
|
||||||
|
atom_id: str
|
||||||
|
project: str
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
body: str
|
||||||
|
plain_text: str
|
||||||
|
word_count: int = 0
|
||||||
|
created_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
||||||
|
|
||||||
|
def to_payload(self) -> dict:
|
||||||
|
return {
|
||||||
|
"atom_id": self.atom_id,
|
||||||
|
"project": self.project,
|
||||||
|
"type": self.type,
|
||||||
|
"title": self.title,
|
||||||
|
"summary": self.summary,
|
||||||
|
"body": self.body,
|
||||||
|
"plain_text": self.plain_text,
|
||||||
|
"word_count": self.word_count,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_atoms(text: str, project: str) -> list[Atom]:
|
||||||
|
"""Эвристический extract: разбивает текст на блоки, классифицирует по паттернам."""
|
||||||
|
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
|
||||||
|
atoms: list[Atom] = []
|
||||||
|
seq = 0
|
||||||
|
|
||||||
|
for ln in lines:
|
||||||
|
if ln.startswith("#") or ln.startswith(">") or ln.startswith("http"):
|
||||||
|
continue
|
||||||
|
if re.match(r"^\|.*\|$", ln) or ln.startswith("---") or ln.startswith("```"):
|
||||||
|
continue
|
||||||
|
if len(ln) < 20:
|
||||||
|
continue
|
||||||
|
seq += 1
|
||||||
|
atoms.append(Atom(
|
||||||
|
atom_id=f"{project}_{classify(ln)}_{seq:04d}",
|
||||||
|
project=project,
|
||||||
|
type=classify(ln),
|
||||||
|
title=ln[:80],
|
||||||
|
summary=ln[:160],
|
||||||
|
body=ln,
|
||||||
|
plain_text=ln,
|
||||||
|
word_count=len(re.findall(r"\S+", ln)),
|
||||||
|
))
|
||||||
|
return atoms
|
||||||
|
|
||||||
|
|
||||||
|
def classify(line: str) -> str:
|
||||||
|
low = line.lower()
|
||||||
|
if re.search(r"\d", line) and re.search(r"[%₽$€]", line):
|
||||||
|
return "stat"
|
||||||
|
if "?" in line:
|
||||||
|
return "qa"
|
||||||
|
if re.search(r"(подпишись|подписка|перейди|заказывай|оставь|свяжись|позвони)", low):
|
||||||
|
return "cta"
|
||||||
|
if re.search(r"(правильно|неправильно|ошибаеш|ты не|вот почему|секрет)", low):
|
||||||
|
return "hook"
|
||||||
|
if re.match(r"\d+[\.\)]", line):
|
||||||
|
return "instruction"
|
||||||
|
if line.startswith("«") or line.startswith("«"):
|
||||||
|
return "quote"
|
||||||
|
if re.search(r"(vs|против|по сравнению|лучше|дороже|дешевле)", low):
|
||||||
|
return "comparison"
|
||||||
|
return "claim"
|
||||||
|
|
||||||
|
|
||||||
|
def embed(text: str) -> list[float]:
|
||||||
|
"""bge-m3 embed через Ollama (dim=1024)."""
|
||||||
|
import urllib.request
|
||||||
|
body = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_URL}/api/embeddings", data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
emb = data["embedding"]
|
||||||
|
if len(emb) != DIM:
|
||||||
|
raise ValueError(f"bge-m3 вернул dim={len(emb)}, ожидалось {DIM}")
|
||||||
|
return emb
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_collection(client: QdrantClient) -> None:
|
||||||
|
if client.collection_exists(COLLECTION):
|
||||||
|
info = client.get_collection(COLLECTION)
|
||||||
|
print(f" ✓ коллекция {COLLECTION} уже существует (dim={info.config.params.vectors.size})")
|
||||||
|
return
|
||||||
|
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
|
||||||
|
)
|
||||||
|
print(f" ✓ коллекция {COLLECTION} создана (dim={DIM}, Cosine)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Spike: extract + bge-m3 + Qdrant dedup")
|
||||||
|
ap.add_argument("--article", action="append", help=".md файл (можно несколько)")
|
||||||
|
ap.add_argument("--project", default="spike", help="project_id (домен)")
|
||||||
|
ap.add_argument("--threshold", type=float, default=0.85, help="порог дедупа (Cosine)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.article:
|
||||||
|
print("Использование: --article file.md [--article file2.md ...]")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
client = QdrantClient(host="127.0.0.1", port=6333)
|
||||||
|
ensure_collection(client)
|
||||||
|
|
||||||
|
all_atoms: list[Atom] = []
|
||||||
|
for path in args.article:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f" ✗ файл не найден: {path}")
|
||||||
|
continue
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
atoms = extract_atoms(text, args.project)
|
||||||
|
all_atoms.extend(atoms)
|
||||||
|
print(f" • {os.path.basename(path)}: {len(atoms)} атомов")
|
||||||
|
|
||||||
|
if not all_atoms:
|
||||||
|
print("Нет атомов для обработки")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"\nЭмбеддинг bge-m3 ({len(all_atoms)} атомов)...")
|
||||||
|
start = time.time()
|
||||||
|
for i in range(0, len(all_atoms), 5):
|
||||||
|
for atom in all_atoms[i:i+5]:
|
||||||
|
emb = embed(atom.body)
|
||||||
|
client.upsert(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
points=[PointStruct(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
vector=emb,
|
||||||
|
payload=atom.to_payload(),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
print(f" • {min(i+5, len(all_atoms))}/{len(all_atoms)}")
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print(f" ✓ загрузили {len(all_atoms)} атомов за {elapsed:.1f}с "
|
||||||
|
f"(~{elapsed/len(all_atoms)*1000:.0f}ms/atom)")
|
||||||
|
|
||||||
|
print(f"\nДедупликация (порог {args.threshold}):")
|
||||||
|
dedup_hits = 0
|
||||||
|
for atom in all_atoms:
|
||||||
|
emb = embed(atom.body)
|
||||||
|
res = client.query_points(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
query=emb,
|
||||||
|
limit=5,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
for hit in res.points:
|
||||||
|
if hit.payload.get("atom_id") == atom.atom_id:
|
||||||
|
continue
|
||||||
|
score = hit.score or 0
|
||||||
|
if score >= args.threshold:
|
||||||
|
dedup_hits += 1
|
||||||
|
other = hit.payload.get("type", "?")
|
||||||
|
print(f" ⚠ ДУБЛЬ: {atom.type} ↔ {other} (score={score:.3f})")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\nИтог: {len(all_atoms)} атомов, {dedup_hits} дублей найдено")
|
||||||
|
print(f"Коллекция: 127.0.0.1:6333 / {COLLECTION}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user