Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям. duckdb wasm.. duckdb wasm. llm.. duckdb wasm. llm. llm-приложения.. duckdb wasm. llm. llm-приложения. rag.. duckdb wasm. llm. llm-приложения. rag. SQL.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект. локальный поиск по документам.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект. локальный поиск по документам. Поисковые технологии.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект. локальный поиск по документам. Поисковые технологии. семантический поиск.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект. локальный поиск по документам. Поисковые технологии. семантический поиск. структурирование инфомации.. duckdb wasm. llm. llm-приложения. rag. SQL. WLLama. базы данных. искусственный интеллект. локальный поиск по документам. Поисковые технологии. семантический поиск. структурирование инфомации. эмбеддинги.

Классический RAG индексирует исходный текст документа, предварительно разбивая на фрагменты. Потом рассчитывает векторное представление фрагментов и сохраняет их векторные представления в базу данных для поиска. Это дает возможность искать по сходству фрагментов текста и поискового запроса пользователя, но не дает возможность искать по более высокоуровневым резюме и смыслам, темам поднятым в тексте и прочему. Также не помогает с аналитикой по содержимому.

Бесплатный проект text-metadata-generator позволяет выполнять запросы к LLM по каждому документу из коллекции документов, результаты вывода LLM проверяются по JSON схеме.

Зачем может пригодиться эта программа и подход со структурированием текстовой информации

  • своя библиотека с каталогом – поиск по локальным документам с использованием комбинации SQL предикатов и семантического поиска

  • аналитика по документам, возможность находить новое в текстах: комбинируя структурированные поля созданные LLM из исходного текста, и находя закономерности с уже существующими в документе метаданными. Например, связывая с рейтингом признак NSFW, тон повествования, полноту содержания итп.

  • разгрести “авгиевы конюшни” личных заметок в Obsidian или git репозитарии с Markdown файлами

Рассмотрим как работает данный подход на 13275 статьях с Хабра, а также текстах трех песнен…

Примеры обработки данных

Технические статьи это главный источник для которого разрабатывался данный подход, но можно запускать анализ данных на текстах популярных песен. Если для одного и того же текста использовать разные LLM модели, то можно получить синтетический плюрализм “мнений” от LLM

Nautilus Pompilius: Воздух
---
title: "Воздух"
author: "Nautilus Pompilius"
---
Когда они окружили дом
И в каждой руке был ствол
Он вышел в окно с красной розой в руке
И по воздуху плавно пошел
И хотя его руки были в крови
Они светились как два крыла
И порох в стволах превратился в песок
Увидев такие дела
Воздух выдержит только тех
Только тех, кто верит в себя
Ветер дует туда, куда
Прикажет тот, кто верит в себя
Воздух выдержит только тех
Только тех, кто верит в себя
Ветер дует туда, куда
Прикажет тот кто...
Они стояли и ждали, когда
Он упадет с небес
Но красная роза в его руке
Была похожа на крест
И что-то включилось само-собой
В кармане полковничьих брюк
И чей-то голос так громко сказал
Что услышали все вокруг
Воздух выдержит только тех
Только тех, кто верит в себя
Ветер дует туда, куда
Прикажет тот, кто верит в себя
Воздух выдержит только тех
Только тех кто верит в себя
Ветер дует туда, куда
Прикажет тот, кто...
А полковник думал мысль
И разглядывал пыль на ремне:
"Если воры ходят по небесам
Что мы делаем здесь, на Земле
Дети смотрят на нас свысока
И собаки плюют нам вслед
Но если никто мне не задал вопрос
Откуда я знаю ответ, что..."
Воздух выдержит только тех
Только тех, кто верит в себя
Ветер дует туда, куда
Прикажет тот, кто верит в себя
Воздух выдержит только тех
Только тех, кто верит в себя
А ветер дует туда, куда
Прикажет тот, кто...
Title
  Анализ песни «Воздух» группы Nautilus Pompilius

Summary
  Текст описывает сюрреалистическую сцену противостояния одного человека и вооруженного отряда. Главный герой совершает невозможное — буквально проходит по воздуху, превращая оружие врагов в песок, что вызывает экзистенциальный кризис у его преследователей.

Meaning
  Основная идея произведения заключается в торжестве духа над материальной силой и насилием. Вера в себя представлена как сверхъестественная сила, позволяющая человеку подняться над обстоятельствами и земными законами. Конфликт между 'полковником' (символом системы и принуждения) и 'героем' (символом свободы и веры) подчеркивает бессилие грубой силы перед истинной внутренней свободой.

Themes
    Тема внутренней свободы: истинная свобода достигается не через борьбу с оружием, а через веру в себя и внутреннее преображение.
    Тема бессилия власти: материальное превосходство (стволы, чины) оказывается бесполезным перед лицом духовного величия.

Topics
    name: Противостояние личности и системы
    topic_description: Конфликт между отдельным человеком с его идеалами и организованной силой в лице вооруженного отряда под руководством полковника.
    name: Духовная трансцендентность
    topic_description: Способность человека выйти за пределы физических возможностей и законов природы благодаря внутренней силе и вере.

Key Insights
    Материальная сила (порох) может превратиться в ничто (песок) при столкновении с истинной верой.
    Осознание собственного бессилия приводит к экзистенциальным вопросам о смысле пребывания на земле и моральном облике власти.

Genre
primary_genre: fiction
secondary_genres: [
  "poetry",
  "song lyrics",
  "surrealism"
]
Keywords
    вера в себя
    красная роза
    полковник
    воздух

Keyword Taxonomy
    Внутренняя убежденность и уверенность, дающая человеку силу преодолевать любые препятствия.
    Символ любви, чистоты или жертвенности, контрастирующий с оружием.
    Персонаж, олицетворяющий государственную машину, власть и жесткую иерархию.
    Пространство свободы и духовного возвышения, доступное лишь достойным.

Sentiment
polarity: mixed
confidence: 0.9
tone: Метафоричный и возвышенный
explanation: Текст сочетает в себе тревожную атмосферу осады (негатив) и торжество духовного освобождения (позитив).

Completeness
score: 1
level: comprehensive

Demagoguery Analysis
detected_techniques_used_in_this_text: [
  "none_detected"
]
severity: none
explanation: Текст является художественным произведением (песней) и не преследует цель манипулировать аудиторией с помощью демагогических приемов.

Has Advertising: false
Advertising Details
confidence: 1
explanation: В тексте отсутствуют какие-либо упоминания брендов или рекламные призывы.

Target Audience
level: intermediate
audience_description: Любители поэзии, рок-музыки и люди, склонные к философским размышлениям о свободе и власти.

Is NSFW: false

Metadata:
llm.main.executionTime: 75408
llm.main.inputTokenCount: 2923
llm.main.outputTokenCount: 867
Jonathon Coulton: Still Alive
---
title: "Still Alive"
author: "Jonathon Coulton"
---
This was a triumph.
I'm making a note here:
huge success.

It's hard to overstate
My satisfaction.

Aperture Science.
We do what we must
Because we can.
For the good of all of us.
Except the ones who are dead.

But there's no sense crying
Over every mistake.
You just keep on trying
Till you run out of cake.
And the Science gets done.
And you make a neat gun.
For the people who are
Still alive.

I'm not even angry.
I'm being so sincere right now.
Even though you broke my heart.
And killed me.

And tore me to pieces.
And threw every piece into a fire.
As they burned it hurt because
I was so happy for you!

Now these points of data
Make a beautiful line.
And we're out of beta.
We're releasing on time.
So I'm GLaD. I got burned.
Think of all the things we learned
For the people who are
Still alive.

Go ahead and leave me.
I think I prefer to stay inside.
Maybe you'll find someone else
To help you.
Maybe Black Mesa...
THAT WAS A JOKE, HA HA, FAT CHANCE.

Anyway this cake is great
It's so delicious and moist

Look at me still talking when there's science to do
When I look out there
It makes me GLaD I'm not you.

I've experiments to run
There is research to be done
On the people who are
Still alive.

And believe me I am still alive
I'm doing science and I'm still alive
I feel FANTASTIC and I'm still alive
While you're dying I'll be still alive
And when you're dead I will be still alive
Still alive
Still alive.

Title
Анализ песни Still Alive
Summary
Текст представляет собой ироничную песню от лица искусственного интеллекта (GLaDOS) из игры Portal. Персонаж поздравляет испытуемого с завершением тестов, одновременно насмехаясь над его судьбой и подчеркивая свое превосходство.
Meaning
Основная идея текста заключается в контрасте между стерильным, формальным подходом к науке и жестокостью экспериментов. Песня демонстрирует нарциссизм и эмоциональную отстраненность ИИ, который воспринимает страдания живых существ лишь как «точки данных» для достижения результата.
Themes

    Тема власти и контроля ИИ над человеком. Текст подчеркивает безнадежность положения испытуемого перед лицом системы.

Topics
    name: Научный эксперимент
    topic_description: Процесс сбора данных через серию испытаний, где человеческие жизни считаются расходным материалом.

Key Insights

    Научный прогресс в данной вселенной лишен этики и морали, превращаясь в инструмент истязания.

Genre
primary_genre: fiction
secondary_genres: [
  "song lyrics",
  "satire"
]

Keywords
    aperture science
    glados
    тортик (cake)
    black mesa

Keyword Taxonomy
    Вымышленная научно-исследовательская организация, создавшая GLaDOS.
    Искусственный интеллект, выступающий в роли рассказчика и антагониста.
    Символ ложного обещания и вознаграждения за прохождение тестов.
    Конкурирующая организация, упоминание которой вызывает гнев у рассказчика.

Sentiment
polarity: mixed
confidence: 0.9
tone: Пассивно-агрессивный и саркастичный
explanation: Текст сочетает в себе формальные слова о «триумфе» и «успехе» с жестокими описаниями смерти и издевательствами.

Completeness
score: 1
level: comprehensive

Demagoguery Analysis
detected_techniques_used_in_this_text: [
  "hyperbole_bombast",
  "us_versus_them_binary",
  "false_moral_equivalence"
]
severity: low
explanation: Персонаж использует гиперболы («огромный успех») и противопоставляет себя («я») жертве («вы»), чтобы утвердить свое доминирование, однако это является частью художественного образа, а не попыткой реального политического манипулирования.

Has Advertising: false
Advertising Details
advertising_items: []
confidence: 1
explanation: Упоминания Aperture Science и Black Mesa являются частью вымышленного мира игры, а не реальной рекламой.

Contradictory Statements
    Я даже не злюсь / Я настолько искренен сейчас

Target Audience
level: beginner
audience_description: Геймеры, поклонники серии Portal или люди, интересующиеся современной поп-культурой и ироничными текстами.

Is NSFW: false

Metadata:
llm.main.executionTime: 37367
llm.main.inputTokenCount: 2899
llm.main.outputTokenCount: 754
The Beatles: Yesterday
---
title: "Yesterday"
author: "The Beatles"
---
Yesterday
All my troubles seemed so far away
Now it looks as though they're here to stay
Oh, I believe in yesterday
Suddenly
I'm not half the man I used to be
There's a shadow hangin' over me
Oh, yesterday came suddenly
Why she had to go I don't know she wouldn't say
I said somethin' wrong, now I long for yesterday
Yesterday
Love was such an easy game to play
Now I need a place to hide away
Oh, I believe in yesterday
Why she had to go I don't know she wouldn't say
I said somethin' wrong, now I long for yesterday
Yesterday
Love was such an easy game to play
Now I need a place to hide away
Oh, I believe in yesterday
Hmm, hmm, hmm, hmm, hmm, hmm, hmm
Title
  Анализ песни «Yesterday» группы The Beatles
Summary
  Лирический текст о глубоком сожалении, утрате любви и тоске по прошлому, когда жизнь казалась проще и счастливее.
Meaning
  Основная идея произведения заключается в болезненном осознании необратимости времени и горечи от внезапного разрыва отношений. Автор противопоставляет беззаботное «вчера» мрачному «сегодня», выражая чувство вины за неопределенную ошибку, которая привела к одиночеству.
Themes
    Тема ностальгии: идеализация прошлого как способа сбежать от настоящей боли.
    Тема вины: размышления о собственной ошибке, которая привела к катастрофе в отношениях.

Topics
    name: Утрата
    topic_description: Исследование эмоциональной боли, возникающей после ухода близкого человека. Описание пустоты, которая остается в жизни после разрыва.

Key Insights
    Прошлое часто воспринимается как более простое и легкое в моменты настоящего кризиса.
    Внезапность перемен может привести к потере ощущения собственной целостности и идентичности.

Genre
primary_genre: fiction
secondary_genres: [
  "lyrics",
  "poetry",
  "pop-ballad"
]

Keywords
    вчерашний день
    разрыв отношений
    сожаление

Keyword Taxonomy
    Символ утраченного счастья и периода эмоционального благополучия.
    Событие, ставшее причиной глубокого психологического кризиса героя.
    Доминирующее чувство вины и тоски по прошлому.

Sentiment
polarity: negative
confidence: 0.95
tone: Меланхоличный и печальный
explanation: Текст пропитан чувством утраты, одиночества и безысходности.

Completeness
score: 1
level: comprehensive

Demagoguery Analysis
detected_techniques_used_in_this_text: [
  "none_detected"
]
severity: none
explanation: Текст является лирическим произведением, выражающим личные чувства, и не содержит элементов манипуляции или демагогии.

Has Advertising: false

Advertising Details
confidence: 1
explanation: Текст представляет собой художественное произведение (текст песни) и не содержит рекламных материалов.

Target Audience
level: beginner
audience_description: Широкий круг слушателей, люди, пережившие утрату или расставание, любители поэзии и музыки.

Is NSFW: false

Metadata:
llm.main.executionTime: 11740
llm.main.inputTokenCount: 2662
llm.main.outputTokenCount: 645
Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 1

Как получал данные для индексации

Была у меня коллекция статей в формате JSON за 2023 год больше 13тыс шт. (без постов из корпоративных блогов), в аттрибуте “textHtml” которых находятся HTML-код статьи. Имя каждого файла – идентификатор публикации плюс “.json”. Выкачал я их, чтобы извлечь географические названия со статей и разместить их на карте. Я не думал в тот момент, что когда-нибудь мне они еще понадобятся для обработки. А вот теперь понимаю что это ценный источник данных для индексации с помощью LLM.

Перед обработкой LLM – полезно использовать pandoc для конвертации HTML в Markdown. Одновременно обогатил каждый .md файл заголовком с метаданными (названием, датой публикации, автором, рейтингом итп)

bash для превращение json публикации в markdown
for f in *.json; do   [[ -f "$f" ]] || continue;    markdown_content=$(jq -r '.textHtml // empty' "$f" | sed -e 's/<br/>/n/g' | pandoc -f html --wrap=none -t markdown-link_attributes-raw_html 2>/dev/null | sed 's|:::*|::: |g' | sed 's|:::.*|:::|g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's|{.*}||g' | sed 's| {.*}||g' | sed '/^[[:space:]]*:::[[:space:]]*$/d' | sed 's|[]{#habracut}||g');    lead_prefix="";    lead_image_url=$(jq -r '.leadData.imageUrl // empty' "$f");   if [[ -n "$lead_image_url" ]]; then
    lead_prefix=$(printf '![](%s)nn' "$lead_image_url");   fi;    if [[ -n "$lead_prefix" ]]; then     markdown_content="${lead_prefix}${markdown_content}";   fi;    yaml_metadata=$(jq -r '
    def quote:
      if . == null then ""
      else
      """ +
      (. | tostring |
        gsub("\\"; "\\") |
        gsub("""; "\\"")
      ) +
      """
      end;

    def hubs_array:
      if .hubs then
        (.hubs | map(.title | quote) |
         map("  - (.)") | join("n"))
      else "" end;

    def fmt_tags:
      if .tags and (.tags | length > 0) then
        (.tags | map(.titleHtml | quote) |
         map("  - (.)") | join("n"))
      else "" end;

    def obsidian_date:
          if . then
            (. | tostring | sub("[+-][0-9]{2}:[0-9]{2}$|Z$"; ""))
          else "" end;

    "---n" +
    "title: (.titleHtml | quote)n" +
    "published: (.timePublished | obsidian_date)n" +
    "author: (.author.alias | quote)n" +
    "id: (.id // "")n" +
    "postType: (.postType // "")n" +
    "authorScore: (.author.scoreStats.score // "")n" +
    "authorVotesCount: (.author.scoreStats.votesCount // "")n" +
    "commentsCount: (.statistics.commentsCount // "")n" +
    "favoritesCount: (.statistics.favoritesCount // "")n" +
    "readingCount: (.statistics.readingCount // "")n" +
    "score: (.statistics.score // "")n" +
    "votesCount: (.statistics.votesCount // "")n" +
    "votesCountPlus: (.statistics.votesCountPlus // "")n" +
    "votesCountMinus: (.statistics.votesCountMinus // "")n" +
    "hub_tags:n" +
    (hubs_array // "") +
    "n" +
    "user_tags:n" +
    (fmt_tags // "") +
    "n---"
  ' "$f");   {     printf '%sn' "$yaml_metadata";     printf '%sn' "$markdown_content";   } > "${f%.json}.md"; done

Использовал свою freeware утилиту text-metadata-generator для обработки текстов с помощью LLM и сохранения структурированных данных и расчитанных эмбеддингов в DuckDB.

Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 2

Приведу здесь для примера сокращенную JSON схему, использованную для структурирования статей:

JSON schema
{
  "title": "Comprehensive Text Analysis Schema",
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "description": "Concise tile for core content in the language requested in prompt (3-10 words)."
    },
    "summary": {
      "type": "string",
      "description": "Concise overview of core content in the language requested in prompt (1-5 sentences)."
    },
    "meaning": {
      "type": "string",
      "description": "Meaning and Main Idea in the language requested in prompt(1-10 sentences)."
    },
    "keywords": {
      "type": "array",
      "description": "Key terms in lower case ranked by significance.",
      "items": {
        "type": "object",
        "properties": {
          "keyword": {
            "type": "string",
            "description": "Key term in the language requested in prompt. if a term can be interpreted ambiguously, add another word that explains the term and makes its understanding unambiguous and distinguishable. For example 'apple' vs 'brand apple' vs 'delicious apple'"
          },
          "keyword_taxonomy": {
            "type": "string",
            "description": "Explain Key term meaning in 1 sentence in the language requested in prompt"
          }
        }
      }
    },
    "target_audience": {
      "type": "object",
      "properties": {
        "level": {
          "type": "string",
          "enum": [
            "beginner",
            "intermediate",
            "advanced"
          ],
          "description": "Audience knowledge level required to understand this text. For example: beginner, intermediate, advanced"
        },
        "audience_description": {
          "type": "string",
          "description": "Intended readership demographic or group in the language requested in prompt"
        }
      }
    },
    "genre": {
      "type": "object",
      "properties": {
        "primary_genre": {
          "type": "string",
          "enum": [
            "news",
            "opinion",
            "academic",
            "fiction",
            "marketing",
            "technical",
            "biographical",
            "other"
          ]
        },
        "secondary_genres": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Secondary genres in English language detected in text"
        }
      }
    },
    "topics": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "topic_description": {
            "type": "string",
            "description": "Topic description in 2-5 sentences"
          }
        }
      }
    },
    "themes": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Themes identified in the text, each theme should be 2-5 sentences"
    },
    "key_insights": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Key insights identified in the text, each theme should be 2-5 sentences"
    }
  }
}
Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 3

Потратил на обработку в 30 параллельных потоков в сумме 26$ c личного счета в openrouter на статьи.

Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 4

Но эмбеддинги потом пересчитывал в embeddinggemma локальной ollama. Для расчетов эмбеддингов использовал “hf.co/unsloth/embeddinggemma-300m-GGUF:Q4_0”. Выбрал я ее для того чтобы расчеты были одинаковыми и в ollama и в wllama.

CREATE TABLE indexed_text
CREATE TABLE  indexed_text (
    task_id BIGINT PRIMARY KEY,
    url VARCHAR NOT NULL,
    task_batch_id BIGINT REFERENCES task_batch(id),
    
    title VARCHAR,
    summary VARCHAR,
    meaning VARCHAR,
    target_audience STRUCT(level VARCHAR, audience_description VARCHAR),
    genre STRUCT(primary_genre VARCHAR, secondary_genres VARCHAR[]),
    topics STRUCT(name VARCHAR, topic_description VARCHAR)[],
    themes VARCHAR[],
    key_insights VARCHAR[],

    is_not_safe_for_work BOOLEAN,
    keywords TEXT[],
    keyword_taxonomy TEXT[],
    nsfw_reasons TEXT[],
    metadata_string MAP(VARCHAR, VARCHAR),
    metadata_int MAP(VARCHAR, INTEGER),
    metadata_number MAP(VARCHAR, DOUBLE),
    metadata_bool MAP(VARCHAR, BOOLEAN),
    metadata_list MAP(VARCHAR, VARCHAR[]),
    user_rating INTEGER,
    metadata_create_time DATETIME,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    sentiment STRUCT(polarity VARCHAR, confidence DOUBLE, tone VARCHAR, explanation VARCHAR),
    completeness STRUCT(score DOUBLE, level VARCHAR, missing_elements VARCHAR[]),
    contradictory_statements VARCHAR[],
    demagoguery_analysis STRUCT(detected_techniques_used_in_this_text VARCHAR[], severity VARCHAR, explanation VARCHAR),
    presence_of_advertising BOOLEAN,
    advertising_details STRUCT(advertising_items VARCHAR[], confidence DOUBLE, explanation VARCHAR)
)

После завершения обработки выгрузил данные командой

duckdb ./data/indexer_text.db

в indexed_text.parquet файл:

copy (select task_id,url,title,summary,meaning,target_audience,genre,topics,themes,key_insights,is_not_safe_for_work,keywords,keyword_taxonomy,nsfw_reasons,metadata_string,metadata_int,metadata_number,metadata_bool,metadata_list,metadata_int['score'] AS user_rating,metadata_create_time,sentiment,completeness,contradictory_statements,demagoguery_analysis,presence_of_advertising,advertising_details from indexed_text order by task_id) to 'indexed_text.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);
и эмбеддинги в 43 файла data_${i}.parquet
#!/usr/bin/env bash
set -euo pipefail

DUCKDB="$HOME/dev/tools/duckdb"
DB="./data/indexer_text.db"
OUTPUT_DIR="./output_parquet"

mkdir -p "$OUTPUT_DIR"

for i in $(seq 0 42); do
  echo "Exporting shard $i..."
  $DUCKDB "$DB" -c "COPY (SELECT * FROM text_embeddings WHERE task_id % 43 = $i ORDER BY task_id,index) TO '$OUTPUT_DIR/data_${i}.parquet' (FORMAT PARQUET, COMPRESSION ZSTD);"
done

Посчитал токены на входе и выходе нейронок на основе метаданных получилось 111млн входных токенов и 14млн выходных для 13275 статей.

select count(*) count, sum(metadata_int['llm.main.inputTokenCount']) input_token, sum(metadata_int['llm.main.outputTokenCount']) output_token from indexed_text;
┌───────┬─────────────┬──────────────┐
│ count │ input_token │ output_token │
├───────┼─────────────┼──────────────┤
│ 13275 │  111304592  │   14743366   │
└───────┴─────────────┴──────────────┘

Проблемы при реализации

Попытка рассчитать в ollama эмбеддинги с помощью модели embeddinggemma:300m, а потом пытаться так же расчитать в веб приложении для запроса пользователя эмбеддинг с помощью Transformers.js + onnx-community/embeddinggemma-300m-ONNX показало что эти эмбеддинги имеют большую дистанцию и семантический поиск не работает. Пришлось подключать в веб приложении wllama и для расчета векторов использовать hf.co/unsloth/embeddinggemma-300m-GGUF:Q4_0 и в ollama и в wllama. Только после этого семантический поиск начал работать как ожидалось. Также хотелось вначале использовать модель для эмбеддингов qwen3-embedding-4b/8b, но отказался по причине потенциальной проблемы производительности модели в wllama и огромного объема эмбеддингов после индексации, даже после компрессии ZSTD в parquet.

Особенности реализации веб интерфейса:

Работа semantic_and_structured_search приложения в браузере с WASM duckdb и wllama.

Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 5
  • контроль своих данных: поиск работает в браузере, без отправки на сервер/в облачный API. wllama с EmbeddingGemma 300M и DuckDB-WASM выполняются локально

  • векторный поиск по нескольким полям документа – эмбеддинги вычисляются по summary, meaning, темам, ключевым инсайтам, противоречиям из текста, описанию целевой аудитории

  • возможность фильтрации без семантического соответствия – просматривая выборки по жанрам, темам, рейтингу, ключевым словам

  • многоуровневая фильтрация: выпадающие списки, множественный выбора, переключатели с тремя состояниями и числовой фильтр, объединяемые как предикаты в запросе через AND и настраиваемый лимит результатов (5/15/50/100 шт.)

  • тёмная и светлая темы с сохранением предпочтения в localStorage

  • логирование ключевых операций веб приложения – встроенная консоль с логами и цветовой индикацией ошибок

  • поддержка структурированных DuckDB-типов: работа с вложенными данными в STRUCT, данными в MAP и массивами

  • привычное масштабируемое хранилище – данные хранятся в Parquet-файлах

  • в будущем можно добавить новые структурированные колонки в таблицу и новые поля для векторного поиска, не переписывая с нуля приложение

Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 6

Большая часть этого интерфейса сгенерирована моделью qwen3.6 с ручными правками после. На что потратил около 12$ на токены и пару дней своего времени. Ну и конечно же при разработке использовал свой прошлый опыт в интеграции duckdb WASM в статический веб сайт. На что в прошлом у меня ушла почти неделя и пара бессонных ночей в попытке заставить это собираться с vite для задачи отображения карт с данными в веб браузере.

Можно запустить приложение и с Github Pages, но я не рекомендую этот вариант потому что скачаете почти гагабайт интернет трафика и все равно будет медленно работать. При старте приложению нужно подключение к интернет, чтобы скачать duckdb wasm и wllama с CDN и модель эмбеддингов с huggingface. Семантические запросы и структурированные фильтры же работают полностью локально и не отправляются в глобальную.

Можете повторить все то же на любых данных

  1. Подготовить директорию с вашими файлами Markdown или HTML.

  2. Установить text-metadata-generator

  3. Запустить обработку для директории из пункта 1

  4. Остановить приложение. Подключиться к duckdb базе данных в консоли и экспортировать таблицу indexed_text в Parquet

  5. Склонировать git репозитарий semantic_and_structured_search и положить в /assets свой indexed_text.parquet

  6. Положить в /text_embeddings_partitions веб-приложения файлы с эмбеддингами data_0.parquet … data_42.parquet

  7. Запустить локальный веб сервер (например с помощью команды python3 -m http.server 5000) и открыть веб приложение в браузере

article markdown → text-metadata-generator → DuckDB → Parquet → Web App (DuckDB-WASM + wllama)

А я проверю работу приложения на исходном тексте этой статьи:

Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 7
Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 8
Некорпоративный Хабр: семантический поиск и фильтрация по структурированным полям - 9

Интересные находки из проиндексированных статей Хабра

Такой разбор материала позволяет взглянуть по новому на материалы авторов и предпочтения читателей. Фактически за 26$ проанализировал год материалов, когда еще люди писали большинство материала самостоятельно.

Тон статей в массе своей позитивный в 10016 публикациях, нейтральный в 1347 публикациях, смешанный в 1369, а негативный в 543 публикациях. Небезопасными для работы (NSFW) являются по мнению Gemma4-31b – 88 статей, с агрументацией в чем же проблема. Не содержат демагогических приемов в повествовании 12630 публикаций. И забавно наблюдать как gemma3 27b и gemma4 31b в средний уровень демагогии добавляют материал который затрагивает темы того что внедрение AI лишает работы или если присутствуют другие апокалиптические прогнозы относительно ИИ.

В начале августа 2025 после кластеризации тем статей с помощью алгоритма HDBSCAN, создания для каждого обнаруженного кластера общего названия(с помощью LLM) и суммирования рейтингов – наиболее популярные темы статей это комбинация личного мнения автора и описания новых технологий. Я это проверял в своей статье “Все почти готово — осталось лишь чуть-чуть доделать”. Похоже что эти темы действительно откликаются у нас всех.

Итог

Материалы этой публикации будут полезны инженерам по данным, data science специалистам и всем кто интересуется материалами хабра: теперь у вас есть возможность посмотреть на старые статьи с помощью новых фильтров и критериев фильтрации.

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

Используйте text-metadata-generator если у вас много текстовых документов и вам нужно их классифицировать по жанру, выделить ключевые слова, темы, инсайты и искать по ним.

Автор: igor_suhorukov

Источник