Перейти к содержанию

Урок 1: первая память — хранилище и поиск по смыслу

В этом уроке бот-географ получит базу знаний и научится отвечать на вопросы, даже если формулировки вопроса и ответа не совпадают дословно.

Концепция: что такое память в LLM-приложении

Модель (ChatGPT, Llama, что угодно) не помнит ничего за пределами тех слов, которые вы прислали прямо сейчас. Память в protoprompt — это отдельное хранилище, куда приложение складывает факты, а потом подкладывает нужные в промпт.

Чтобы найти нужный факт, хранилище использует векторы:

  • Каждый текст переводится в эмбеддинг — список чисел, «отпечаток смысла» текста.
  • Похожие по смыслу тексты получают похожие отпечатки.
  • Поиск — это сравнение: чем ближе векторы, тем ближе смысл.

Сравнение считается как косинусная близость: результат от 0 (совсем не похоже) до 1 (почти одно и то же). Бот не ищет по точному совпадению слов — он ищет по совпадению смысла.

Практика: читаем проект

Откройте examples/tutorials/01_first_bot/main.py. Он состоит из трёх частей.

Шаг 1. Заглушка LLM

class FakeLLM:
    async def embed(self, texts, model=""):
        return [self._embed(text) for text in texts]

В настоящем проекте здесь был бы Ollama или OpenAI. Заглушка просто собирает эмбеддинг из слов: каждое слово красит свою «точку» в векторе. Примитивно, но работает офлайн и позволяет почувствовать механику.

Шаг 2. Кладём факты в память

store = InMemStore()

for i, (text, meta) in enumerate(FACTS):
    store.add(f"fact-{i}", [text], await llm.embed([text]), meta)

Что здесь происходит:

  • InMemStore() — хранилище в оперативке. Всё пропадёт при перезапуске — для урока это нормально.
  • store.add(doc_id, chunks, embeddings, metadata) — кладёт документ. Три аргумента понятны из имени:
    • "fact-0" — имя документа;
    • [text] — список кусков текста;
    • await llm.embed([text]) — эмбеддинги этих кусков.
  • meta — ярлык-метаданные (например, тема факта).

Повторное добавление

Если добавить документ с тем же doc_id ещё раз, старые чанки этого документа заменятся, а не продублируются.

Шаг 3. Ищем ответ

hits = store.query(
    (await llm.embed([question]))[0],
    top_k=1,
    score_threshold=0.2,
)

store.query — сердце поиска:

  • первый аргумент — эмбеддинг вопроса;
  • top_k=1 — вернуть самый похожий чанк;
  • score_threshold=0.2 — не считать ответом то, что похоже слишком слабо.

Похожесть хранится в поле score каждого результата:

print(f"Похожесть: {best['score']:.2f}")
print(f"Ответ: {best['document']}")

Что увидите при запуске

Вопрос: Какая столица у Франции?
  Похожесть: 0.38
  Ответ: Париж — столица Франции, стоит на реке Сена.

Вопрос: Кто придумал Python?
  Похожесть: 0.65
  Ответ: Python — язык программирования...

Вопрос: Сколько будет 2+2?
  (в памяти ничего похожего нет)

Обратите внимание на «Сколько будет 2+2?»: в базе что-то есть, но ничего не похоже по смыслу — поэтому score ниже порога и ответа нет.

Задание

  1. Добавьте в FACTS три своих факта (любая тема) и перезапустите.
  2. Найдите факт, который не найдет бот, хотя факт в базе есть. Почему так? (Подсказка: заглушка ловит только совпавшие слова.)
  3. Попробуйте фильтр по теме. Замените вызов query на:
hits = store.query(..., where={"topic": "география"})

Вопрос про Python теперь не должен находить ничего.

  1. Замените InMemStore() на SqliteStore("geo.db") и запустите дважды. Первый раз бот добавит факты, второй раз — заменит теми же. База сохраняется между запусками.

Дальше

Вы умеете хранить и находить факты. Но живой диалог — это не база фактов, а переписка, которая растёт с каждым сообщением. Что с ней делать, когда она перестаёт влезать в окно модели? Урок 2 — про сжатие сессии.