
Как устроен поиск по смыслу: embeddings, зачем резать текст на чанки, схема хранения и рабочий пример на Python от документов до cosine search. Почему один вектор на книгу ломает точность?
2 просмотра
1 месяц назад
Короткие выводы: семантический поиск сравнивает смысл текстов через векторы, а не совпадение слов.
text-embedding-3-small от OpenAI возвращает вектор размерности 1536. Близкие смыслы дают близкие направления в пространстве. Метрику близости считают через cosine similarity.tsvector) ищет совпадение токенов. Он силён на точных терминах, кодах и именах. Семантический поиск сильнее на перефразировках. На практике часто комбинируют оба подхода — hybrid search. В исследовании OpenHelm hybrid дал прирост точности около 11 процентных пунктов относительно чистого vector search.Короткие выводы: embedding кодирует смысл; keyword-индекс кодирует слова. Вместе они покрывают больше запросов.
document_id, chunk_order, content, embedding.Text
документ → full_text → chunks → embeddings → таблица чанков ↓ запрос → embedding запроса → поиск ближайших → релевантные фрагменты
Короткие выводы: ищут не «документ в среднем», а конкретный фрагмент с ответом.
content и свой embedding.Короткие выводы: чанки дают разрешение поиска. Среднее по всей книге ≠ поиск по нужному куску.
RecursiveCharacterTextSplitter около 400 токенов даёт recall порядка 85–90%. Overlap 10–20% стабильно улучшает результат. Жёсткое «только абзацы» проигрывает гибриду на реальных документах.Короткие выводы: абзац — предпочтительная граница. Потолок по токенам и overlap обязательны.
embedding на всём документе для поиска не подходит. Нужна таблица чанков.pgvector или любая vector DB):SQL
CREATE TABLE documents ( id TEXT PRIMARY KEY, title TEXT NOT NULL, full_text TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE document_chunks ( id BIGSERIAL PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, chunk_order INT NOT NULL, content TEXT NOT NULL, embedding VECTOR(1536) NOT NULL, -- размерность зависит от модели UNIQUE (document_id, chunk_order) ); CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops);
Короткие выводы: документ хранит полный текст; поиск работает по таблице чанков с векторами.
Bash
pip install openai numpy langchain-text-splitters
Python
DOCUMENTS = [ { "id": "doc_refund", "title": "Возврат средств", "full_text": ( "Возврат оформляют в личном кабинете в разделе «Платежи».\n\n" "Срок возврата — 14 дней с даты оплаты. Деньги приходят на ту же карту.\n\n" "Частичный возврат доступен, если товар ещё не отгрузили." ), }, { "id": "doc_ship", "title": "Доставка", "full_text": ( "Доставка занимает 3–5 рабочих дней по России.\n\n" "Международная доставка занимает до 21 дня.\n\n" "Трек-номер появляется в кабинете после передачи заказа курьеру." ), }, ]
Python
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, # ориентир в символах; в проде лучше считать токены chunk_overlap=60, # ~15% separators=["\n\n", "\n", ". ", " ", ""], ) def make_chunks(doc: dict) -> list[dict]: parts = splitter.split_text(doc["full_text"]) return [ { "document_id": doc["id"], "chunk_order": i, "content": part, } for i, part in enumerate(parts) ]
\n\n), затем по строкам и предложениям. Абзац остаётся целым, пока укладывается в лимит. Это и есть гибрид «абзац + потолок размера».Python
import os from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) MODEL = "text-embedding-3-small" def embed_texts(texts: list[str]) -> list[list[float]]: response = client.embeddings.create(model=MODEL, input=texts) # API возвращает объекты с index — сортируем на всякий случай sorted_data = sorted(response.data, key=lambda x: x.index) return [item.embedding for item in sorted_data]
pgvector, Qdrant, Chroma или SurrealDB. Для демонстрации хватает списка словарей.Python
import numpy as np store: list[dict] = [] for doc in DOCUMENTS: chunks = make_chunks(doc) vectors = embed_texts([c["content"] for c in chunks]) for chunk, vector in zip(chunks, vectors): store.append({**chunk, "embedding": np.array(vector, dtype=np.float64)})
Python
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def search(query: str, top_k: int = 3) -> list[dict]: query_vec = np.array(embed_texts([query])[0], dtype=np.float64) scored = [] for row in store: score = cosine_similarity(query_vec, row["embedding"]) scored.append({**row, "score": score}) scored.sort(key=lambda x: x["score"], reverse=True) return scored[:top_k] results = search("как вернуть деньги за заказ") for r in results: print(f"{r['score']:.3f} | {r['document_id']}#{r['chunk_order']}") print(r["content"]) print("---")
doc_refund про возврат, даже без слов «вернуть деньги» в тексте. Запрос про трек-номер вытянет чанк из doc_ship.Python
def build_context(query: str, top_k: int = 3) -> str: hits = search(query, top_k=top_k) blocks = [ f"[{h['document_id']}#{h['chunk_order']}]\n{h['content']}" for h in hits ] return "\n\n".join(blocks) # context = build_context("срок возврата средств") # дальше: system + context + вопрос пользователя → chat completions
Короткие выводы: минимальный контур — chunk → embed → store → cosine search → контекст для ответа.
Python
def mean_pool(vectors: list[np.ndarray]) -> np.ndarray: stacked = np.stack([v / np.linalg.norm(v) for v in vectors]) mean = stacked.mean(axis=0) return mean / np.linalg.norm(mean) # чанки одной «книги» про разные темы book_chunks = [ "Глава про доставку: сроки 3–5 дней по России.", "Глава про возврат: оформить заявку в кабинете за 14 дней.", "Глава про бонусы: кешбэк 5% за повторную покупку.", ] book_vectors = [np.array(v) for v in embed_texts(book_chunks)] book_mean = mean_pool(book_vectors) query = "как оформить возврат" q = np.array(embed_texts([query])[0]) print("score к среднему по книге:", cosine_similarity(q, book_mean)) print("score к чанку про возврат:", cosine_similarity(q, book_vectors[1])) print("score к чанку про доставку:", cosine_similarity(q, book_vectors[0]))
Короткие выводы: для индексации источников хранят отдельный вектор на каждый чанк. Mean pooling оставляют для коротких сущностей с лимитом входа.
Короткие выводы: продакшен упирается в переиндексацию, единую модель и измерение recall, а не в «магическое» поле embedding на документе.
Информацию подготовили
124 активных участника
Здесь структурирована база знаний приложения