<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yamayu-dev</title>
    <description>The latest articles on DEV Community by yamayu-dev (@yamayu-dev).</description>
    <link>https://dev.to/yamayu-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012826%2F30671a5e-7ec6-494c-94e0-d3770e674e4e.webp</url>
      <title>DEV Community: yamayu-dev</title>
      <link>https://dev.to/yamayu-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yamayu-dev"/>
    <language>en</language>
    <item>
      <title>Building local hybrid search with Qdrant and BGE-M3</title>
      <dc:creator>yamayu-dev</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:38:47 +0000</pubDate>
      <link>https://dev.to/yamayu-dev/building-local-hybrid-search-with-qdrant-and-bge-m3-3b9</link>
      <guid>https://dev.to/yamayu-dev/building-local-hybrid-search-with-qdrant-and-bge-m3-3b9</guid>
      <description>&lt;p&gt;I wanted a local semantic search setup for internal documents: no hosted vector database, no embedding API, and enough flexibility to compare dense search, sparse search, hybrid search, and reranking.&lt;/p&gt;

&lt;p&gt;The interesting part was not "how to start Qdrant." It was how to store both dense and sparse vectors for the same document chunk, then switch the retrieval strategy at query time.&lt;/p&gt;

&lt;p&gt;This is the shape I ended up testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Qdrant running locally in Docker&lt;/li&gt;
&lt;li&gt;BGE-M3 as a local embedding model&lt;/li&gt;
&lt;li&gt;Qdrant named vectors: &lt;code&gt;dense&lt;/code&gt; and &lt;code&gt;sparse&lt;/code&gt; on the same point&lt;/li&gt;
&lt;li&gt;dense search, sparse search, hybrid search with RRF&lt;/li&gt;
&lt;li&gt;optional cross-encoder reranking with &lt;code&gt;BAAI/bge-reranker-v2-m3&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a small PoC, not a benchmark. The goal is to make the moving parts explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why BGE-M3 with Qdrant named vectors?
&lt;/h2&gt;

&lt;p&gt;BGE-M3 can produce dense embeddings and sparse lexical weights from the same model family. Qdrant can store multiple vectors on a single point using named vectors.&lt;/p&gt;

&lt;p&gt;That combination is convenient for document search because each chunk can be stored once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dense_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sparse_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At query time, I can choose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;using="dense"&lt;/code&gt; for semantic search&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;using="sparse"&lt;/code&gt; for lexical-style search&lt;/li&gt;
&lt;li&gt;both, then fuse rankings with Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;a second reranking stage for the top candidates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical benefit is that I do not need to rebuild the collection just to compare retrieval modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local setup
&lt;/h2&gt;

&lt;p&gt;The environment I used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS on Apple Silicon&lt;/li&gt;
&lt;li&gt;Python 3.13&lt;/li&gt;
&lt;li&gt;Docker 28.x&lt;/li&gt;
&lt;li&gt;Qdrant in a local Docker container&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Install the Python packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip setuptools wheel
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"qdrant-client&amp;gt;=1.15.0"&lt;/span&gt; &lt;span class="s2"&gt;"pydantic&amp;gt;=2.7,&amp;lt;2.12"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sentence-transformers FlagEmbedding scipy &lt;span class="se"&gt;\&lt;/span&gt;
  torch torchvision torchaudio &lt;span class="se"&gt;\&lt;/span&gt;
  pandas pymupdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Download BGE-M3 locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
from huggingface_hub import snapshot_download

snapshot_download(
    "BAAI/bge-m3",
    local_dir="./models/bge-m3",
    local_dir_use_symlinks=False,
)
print("Downloaded to ./models/bge-m3")
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start Qdrant with persistent local storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--name&lt;/span&gt; qdrant &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 6333:6333 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/qdrant_storage:/qdrant/storage &lt;span class="se"&gt;\&lt;/span&gt;
  qdrant/qdrant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After startup, the dashboard is available at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:6333/dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Test corpus
&lt;/h2&gt;

&lt;p&gt;For the first pass, I used a tiny mixed-language corpus. It is too small to prove quality, but good enough to check whether each retrieval path behaves as expected.&lt;/p&gt;

&lt;p&gt;I kept the sample texts in Japanese because the real target is Japanese internal documentation. For readers who do not read Japanese, this is the meaning of each row:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;Text&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;今日は天気がいい&lt;/td&gt;
&lt;td&gt;The weather is nice today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;年末年始の休暇規定を確認してください&lt;/td&gt;
&lt;td&gt;Check the year-end and New Year vacation policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;申請の締切日は12月15日です&lt;/td&gt;
&lt;td&gt;The application deadline is December 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;API 標準設計ガイドの参照はこちら&lt;/td&gt;
&lt;td&gt;See here for the standard API design guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;会議は月曜日に延期になりました&lt;/td&gt;
&lt;td&gt;The meeting was postponed to Monday&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;正しいPythonの環境構築方法&lt;/td&gt;
&lt;td&gt;How to set up a proper Python environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Please check the holiday policy for New Year vacation.&lt;/td&gt;
&lt;td&gt;English sentence with similar holiday-policy meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TEXTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;今日は天気がいい&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;年末年始の休暇規定を確認してください&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;申請の締切日は12月15日です&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API 標準設計ガイドの参照はこちら&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;会議は月曜日に延期になりました&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;正しいPythonの環境構築方法&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Please check the holiday policy for New Year vacation.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;正月の休暇と申請締切&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TOP_K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query means:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;正月の休暇と申請締切&lt;/td&gt;
&lt;td&gt;New Year vacation and the application deadline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In this corpus, the relevant answers are split across multiple sentences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;holiday policy: &lt;code&gt;年末年始の休暇規定を確認してください&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;deadline: &lt;code&gt;申請の締切日は12月15日です&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;related English sentence: &lt;code&gt;Please check the holiday policy for New Year vacation.&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Normalizing BGE-M3 sparse output
&lt;/h2&gt;

&lt;p&gt;One detail that is easy to miss: BGE-M3 sparse output may come back in different shapes depending on the library path. I normalized both forms into Qdrant's &lt;code&gt;SparseVector&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client.http.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparseVector&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_qdrant_sparse_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m3_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;sv_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse_vecs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m3_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m3_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse_vecs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;sv_list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lexical_weights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m3_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m3_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lexical_weights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="n"&gt;sv_list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;SparseVector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No sparse output found in BGEM3 result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sv_list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating dense, sparse, or hybrid collections
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdn9k6jfcn5bxin2j4j4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdn9k6jfcn5bxin2j4j4u.png" alt=" " width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a dense-only collection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VectorParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COSINE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a sparse-only collection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sparse_vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SparseVectorParams&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the hybrid version, store both named vectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VectorParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COSINE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;sparse_vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SparseVectorParams&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last shape is the one I would normally use for document search. It keeps the operational model simple: one point per chunk, both retrieval signals attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upserting both vectors on the same point
&lt;/h2&gt;

&lt;p&gt;Dense embeddings came from &lt;code&gt;SentenceTransformer&lt;/code&gt; using the local BGE-M3 directory. Sparse vectors came from &lt;code&gt;BGEM3FlagModel&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;FlagEmbedding&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BGEM3FlagModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client.http.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PointStruct&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;


&lt;span class="n"&gt;dense_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./models/bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;m3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BGEM3FlagModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_fp16&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dense_vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;dense_model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TEXTS&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;sparse_out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEXTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_sparse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sparse_vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;to_qdrant_sparse_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sparse_out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;id_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sparse_vec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEXTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;TEXTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dense_vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sparse_vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;id_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dense_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sparse_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Dense and sparse search
&lt;/h2&gt;

&lt;p&gt;Dense search uses the &lt;code&gt;dense&lt;/code&gt; vector name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_dense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;qvec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dense_model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qvec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;search_params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hnsw_ef&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sparse search uses the &lt;code&gt;sparse&lt;/code&gt; vector name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_sparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;return_sparse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;q_sp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;to_qdrant_sparse_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;q_sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hybrid search with RRF
&lt;/h2&gt;

&lt;p&gt;For the hybrid mode, I searched dense and sparse separately, then fused the ranked IDs with Reciprocal Rank Fusion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_fuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;id_list&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;id_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id_list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;pid&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search function is intentionally boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_hybrid_rrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dense_hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_dense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dense_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sparse_hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_sparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;dense_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dense_hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;sparse_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sparse_hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;fused_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rrf_fuse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;dense_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sparse_ids&lt;/span&gt;&lt;span class="p"&gt;])[:&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;by_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dense_hits&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sparse_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fused_ids&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used RRF here because it only needs ranks, not comparable raw scores from different retrieval systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional cross reranking
&lt;/h2&gt;

&lt;p&gt;After first-stage retrieval, I optionally reranked the candidates with &lt;code&gt;BAAI/bge-reranker-v2-m3&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;FlagEmbedding&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FlagReranker&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cross_rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FlagReranker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-reranker-v2-m3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_fp16&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;max_chars&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;qtext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compute_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_cross&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_cross&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a second stage, not a replacement for the index. In a larger system I would keep the rerank candidate set bounded, for example by reranking only the top 20 to 50 candidates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the small test showed
&lt;/h2&gt;

&lt;p&gt;I ran six combinations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; dense  &lt;span class="nt"&gt;--rerank&lt;/span&gt; none
python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; dense  &lt;span class="nt"&gt;--rerank&lt;/span&gt; cross
python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; sparse &lt;span class="nt"&gt;--rerank&lt;/span&gt; none
python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; sparse &lt;span class="nt"&gt;--rerank&lt;/span&gt; cross
python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; both   &lt;span class="nt"&gt;--rerank&lt;/span&gt; none
python test_index_and_rerank.py &lt;span class="nt"&gt;--index&lt;/span&gt; both   &lt;span class="nt"&gt;--rerank&lt;/span&gt; cross
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dense search without reranking put the holiday policy first, the English holiday sentence second, and the deadline third:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INDEX = DENSE, SEARCH = DENSE   RERANK=NONE
Query: "正月の休暇と申請締切"

score=0.727  id=2  text=年末年始の休暇規定を確認してください
score=0.664  id=7  text=Please check the holiday policy for New Year vacation.
score=0.617  id=3  text=申請の締切日は12月15日です
score=0.474  id=5  text=会議は月曜日に延期になりました
score=0.378  id=1  text=今日は天気がいい
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sparse search put the deadline first and the holiday policy second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INDEX = SPARSE, SEARCH = SPARSE   RERANK=NONE
Query: "正月の休暇と申請締切"

score=0.097  id=3  text=申請の締切日は12月15日です
score=0.071  id=2  text=年末年始の休暇規定を確認してください
score=0.001  id=6  text=正しいPythonの環境構築方法
score=0.000  id=5  text=会議は月曜日に延期になりました
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hybrid search plus cross reranking gave this order in the same small test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INDEX = BOTH, SEARCH = HYBRID(RRF)   RERANK=CROSS
Query: "正月の休暇と申請締切"

score=0.617  cross=0.030  id=3  text=申請の締切日は12月15日です
score=0.727  cross=0.025  id=2  text=年末年始の休暇規定を確認してください
score=0.664  cross=0.020  id=7  text=Please check the holiday policy for New Year vacation.
score=0.474  cross=0.000  id=5  text=会議は月曜日に延期になりました
score=0.001  cross=0.000  id=6  text=正しいPythonの環境構築方法
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I would not read too much into the exact scores from such a small corpus. The useful observation was simpler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dense search captured semantic and cross-language similarity better in this test&lt;/li&gt;
&lt;li&gt;sparse search surfaced lexical matches such as the deadline sentence&lt;/li&gt;
&lt;li&gt;hybrid search let both paths contribute candidates&lt;/li&gt;
&lt;li&gt;reranking changed the order of the top candidates, so it should be evaluated separately from retrieval&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Issues I hit
&lt;/h2&gt;

&lt;p&gt;The main problems were ordinary integration issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vector dimension mismatch: if the model dimension does not match the collection's configured vector size, recreate the collection.&lt;/li&gt;
&lt;li&gt;Sparse vector support: use a recent &lt;code&gt;qdrant-client&lt;/code&gt;; I used &lt;code&gt;qdrant-client&amp;gt;=1.15.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pydantic compatibility: I pinned &lt;code&gt;pydantic&amp;gt;=2.7,&amp;lt;2.12&lt;/code&gt; in this environment.&lt;/li&gt;
&lt;li&gt;Model download size: the first BGE-M3 download needs several GB of disk space.&lt;/li&gt;
&lt;li&gt;Docker persistence: without &lt;code&gt;-v $(pwd)/qdrant_storage:/qdrant/storage&lt;/code&gt;, deleting the container also removes the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I would use this for real documents
&lt;/h2&gt;

&lt;p&gt;For actual internal documents, I would keep the same retrieval structure and add a document ingestion pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;extract text from &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.md&lt;/code&gt;, and &lt;code&gt;.pdf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;split into chunks with overlap&lt;/li&gt;
&lt;li&gt;store &lt;code&gt;dense&lt;/code&gt; and &lt;code&gt;sparse&lt;/code&gt; vectors on the same Qdrant point&lt;/li&gt;
&lt;li&gt;include payload fields such as &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;page&lt;/code&gt;, &lt;code&gt;chunk_index&lt;/code&gt;, and &lt;code&gt;text&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;query with dense, sparse, or hybrid mode depending on the use case&lt;/li&gt;
&lt;li&gt;optionally rerank a bounded candidate set&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My default would be to ingest both dense and sparse vectors from the beginning. Even if the first UI only exposes semantic search, having both vectors available makes later experiments much cheaper.&lt;/p&gt;

&lt;p&gt;Qdrant docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Named vectors: &lt;a href="https://qdrant.tech/documentation/concepts/vectors/#named-vectors" rel="noopener noreferrer"&gt;https://qdrant.tech/documentation/concepts/vectors/#named-vectors&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sparse vectors: &lt;a href="https://qdrant.tech/documentation/concepts/vectors/#sparse-vectors" rel="noopener noreferrer"&gt;https://qdrant.tech/documentation/concepts/vectors/#sparse-vectors&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BGE-M3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model: &lt;a href="https://huggingface.co/BAAI/bge-m3" rel="noopener noreferrer"&gt;https://huggingface.co/BAAI/bge-m3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FlagEmbedding: &lt;a href="https://github.com/FlagOpen/FlagEmbedding" rel="noopener noreferrer"&gt;https://github.com/FlagOpen/FlagEmbedding&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions.&lt;/p&gt;

</description>
      <category>qdrant</category>
      <category>python</category>
    </item>
    <item>
      <title>Building on-device Video Notes in a macOS app</title>
      <dc:creator>yamayu-dev</dc:creator>
      <pubDate>Fri, 03 Jul 2026 11:16:01 +0000</pubDate>
      <link>https://dev.to/yamayu-dev/building-on-device-video-notes-in-a-macos-app-g9a</link>
      <guid>https://dev.to/yamayu-dev/building-on-device-video-notes-in-a-macos-app-g9a</guid>
      <description>&lt;h1&gt;
  
  
  Building on-device Video Notes: SpeechAnalyzer, Foundation Models, and libmpv in a shipping macOS app
&lt;/h1&gt;

&lt;p&gt;My macOS video player, &lt;strong&gt;Reel&lt;/strong&gt;, has a feature called Video Notes: pick a local video, click Generate, and get a timestamped transcript, an optional translation, and a structured summary — &lt;strong&gt;entirely on-device&lt;/strong&gt;. No API keys, no upload, works on an mkv file of a two-hour lecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1j1gvgfe9uht7zubk2pv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1j1gvgfe9uht7zubk2pv.png" alt=" " width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pipeline is four stages, each on a different technology:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fis02zny5aluafxziofe8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fis02zny5aluafxziofe8.png" alt=" " width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apple's 2025 APIs demo beautifully. Shipping them is a different story — the interesting bugs are all in the last stage. Here's what each stage looks like in production, and every workaround I needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 1: audio extraction with headless libmpv (&lt;code&gt;ao=pcm&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Why not AVFoundation? Because it isn't enough for a local-video library: mkv and webm — both common in the wild — won't open at all. Reel already ships libmpv for playback, and libmpv has a trick that makes it a universal audio extractor &lt;strong&gt;without any encoder&lt;/strong&gt;: the PCM audio output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mpv_create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mpv_set_option_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"terminal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"msg-level"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"all=no"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// headless: no video, no window&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ao"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pcm"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                             &lt;span class="c1"&gt;// "play" audio into a WAV file&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ao-pcm-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ao-pcm-waveheader"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"audio-samplerate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"16000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;// what speech models want&lt;/span&gt;
&lt;span class="nf"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"audio-channels"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mono"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="nf"&gt;mpv_initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;mpv_terminate_destroy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// mpv_command(h, ["loadfile", url.path]) …then pump events until END_FILE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You "play" the file with video disabled and the audio device replaced by a WAV writer; decoding runs much faster than realtime. Anything mpv can demux — mkv, webm, avi, and the other containers AVFoundation rejects — becomes a 16 kHz mono WAV. Two production notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pump &lt;code&gt;mpv_wait_event&lt;/code&gt; until &lt;code&gt;MPV_EVENT_END_FILE&lt;/code&gt;, &lt;strong&gt;with a deadline&lt;/strong&gt;. A pathological input that never emits &lt;code&gt;END_FILE&lt;/code&gt; would otherwise hang your pipeline forever.&lt;/li&gt;
&lt;li&gt;Sanity-check the output (exists, &amp;gt; 1 KB) before declaring success; a video with no audio track "succeeds" into an empty file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stage 2: transcription with SpeechAnalyzer (macOS 26)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SpeechAnalyzer&lt;/code&gt; / &lt;code&gt;SpeechTranscriber&lt;/code&gt; is the new speech stack — on-device, fast (it chewed through long files far quicker than realtime in my testing), and notably &lt;strong&gt;it does not require Apple Intelligence&lt;/strong&gt;, just a model download. Three things the sample code doesn't emphasize:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Ask for timestamps via &lt;code&gt;attributeOptions&lt;/code&gt;.&lt;/strong&gt; I want a transcript that &lt;em&gt;seeks the player&lt;/em&gt; when you click a line, which means per-segment timing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;transcriptionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="nv"&gt;reportingOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="nv"&gt;attributeOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audioTimeRange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// ← per-run timing, for click-to-seek&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timing comes back as attributed-string runs; read the first run carrying an &lt;code&gt;audioTimeRange&lt;/code&gt; to get a segment's start time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Model download is your job to surface.&lt;/strong&gt; First use of a locale requires the asset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can take a while on first run — show progress, don't just spin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Consume results concurrently with feeding the file.&lt;/strong&gt; &lt;code&gt;transcriber.results&lt;/code&gt; is an async sequence; collect it in a &lt;code&gt;Task&lt;/code&gt; while &lt;code&gt;analyzer.analyzeSequence(from:)&lt;/code&gt; consumes the audio file, then &lt;code&gt;finalizeAndFinish(through:)&lt;/code&gt; the last sample. Collecting after the fact deadlocks; the collector must already be draining.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: translation — the framework that only lives inside SwiftUI
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Translation&lt;/code&gt; framework does on-device EN↔JA (and more) with no Apple Intelligence requirement. Its one architectural surprise: &lt;strong&gt;you can't just instantiate a translation session in your service layer.&lt;/strong&gt; A &lt;code&gt;TranslationSession&lt;/code&gt; is only vended through SwiftUI's &lt;code&gt;.translationTask&lt;/code&gt; modifier, so the translation stage is driven from the view/model layer, not from the same stateless service as the rest of the pipeline. Plan your architecture around that asymmetry; my pipeline service does extraction/transcription/summary, and the model that owns the SwiftUI surface drives translation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: summarization with Foundation Models — where the production issues showed up
&lt;/h2&gt;

&lt;p&gt;This is the stage that "works in the demo" and then fails four different ways on real content. Every workaround below is in shipping code.&lt;/p&gt;

&lt;h3&gt;
  
  
  4a. The default guardrails refuse ordinary content
&lt;/h3&gt;

&lt;p&gt;My first end-to-end test summarized a tourism clip fine, then refused an ordinary interview video. The default safety guardrails false-positive on perfectly normal human conversation — and summarizing &lt;em&gt;the user's own file&lt;/em&gt; is exactly the use case Apple provides relaxed guardrails for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;summaryModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kt"&gt;SystemLanguageModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;guardrails&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;permissiveContentTransformations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switching to &lt;code&gt;.permissiveContentTransformations&lt;/code&gt; (the mode intended for content-transformation tasks over user-provided material) cut the false blocks dramatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  4b. Tell guardrail refusals apart from real failures
&lt;/h3&gt;

&lt;p&gt;When the model does refuse, users deserve a different message than "generation failed":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;GenerationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;guardrailViolation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;   &lt;span class="c1"&gt;// won't change on retry&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;none&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.blocked&lt;/code&gt; gets its own UI string; retrying a safety block is pointless, so don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  4c. The context window is small — map-reduce long transcripts
&lt;/h3&gt;

&lt;p&gt;A single &lt;code&gt;respond(to:)&lt;/code&gt; with a long transcript just throws: context window exceeded, no summary for exactly the videos that need one most. The fix is the classic map-reduce: chunk the transcript (~6,000 characters per request), summarize each chunk with a neutral prompt, then summarize the concatenated partial summaries with the user's preferences applied to the final pass. The chunker splits on whitespace, which means word boundaries in space-separated languages. Japanese has no internal spaces, but the transcript is assembled by joining timestamped segments with spaces, so it still chunks correctly — on segment boundaries. If even the combined partials exceed the budget, ship the concatenation rather than fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  4d. The on-device model gets stuck in repetition loops
&lt;/h3&gt;

&lt;p&gt;Occasionally the model emits the same sentence over and over — a classic greedy-decoding failure. Three defenses, all needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A little temperature.&lt;/strong&gt; &lt;code&gt;GenerationOptions(temperature: 0.6)&lt;/code&gt; avoids most loops outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collapse what still repeats.&lt;/strong&gt; Post-process: dedupe identical adjacent lines, then identical adjacent sentences within a line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect degenerate output and retry once.&lt;/strong&gt; If, after collapsing, more than half the sentences are duplicates, the model looped — regenerate once, then give up rather than show garbage:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;isDegenerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;units&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;whereSeparator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"。"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"."&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimmingCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whitespaces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;   &lt;span class="c1"&gt;// over half are duplicates&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4e. Smaller lessons from the same stage
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain text beat Guided Generation for this task.&lt;/strong&gt; Structured (guided) output occasionally came back malformed; a strictly-specified plain-text format ("1–2 sentence overview, blank line, 3–6 bullets") parses trivially and never broke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't summarize the un-summarizable.&lt;/strong&gt; Below ~140 characters of transcript, a "summary" just restates the input; skip the stage and point users at the transcript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-language output needs shouting.&lt;/strong&gt; Producing a Japanese summary of an English transcript works, but only if the instructions say, in effect, &lt;em&gt;CRITICAL: write the ENTIRE summary, including headings, in Japanese&lt;/em&gt; — a polite request gets you mixed-language output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transcript is noisy input — say so.&lt;/strong&gt; One instruction line ("the transcript is auto-generated; silently correct obvious proper-noun misrecognitions") noticeably improves summaries of technical content.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Degrade gracefully
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SystemLanguageModel.default.availability&lt;/code&gt; tells you whether Apple Intelligence is on. When it isn't, only the summary stage dies — transcription, translation, and frame captures all still work, so the feature stays useful on machines without Apple Intelligence. Check availability up front and disable exactly one stage, not the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  Assembly notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generate once, persist, reload instantly.&lt;/strong&gt; The pipeline takes seconds to minutes; results are written as JSON (plus captured frames) in Application Support, keyed by a path-derived stable video ID. Regeneration is explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every stage is independent.&lt;/strong&gt; Extraction/transcription need nothing from Apple Intelligence; translation is optional and on-demand; summary is the only gated stage. Model the pipeline that way and each capability degrades separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame captures reuse the player's engine.&lt;/strong&gt; Timestamped captures come from the same headless-libmpv screenshot path the player uses for thumbnails, so "capture this moment" costs nothing extra. The end goal is a portable Markdown export — summary, highlights with images, transcript — that opens in Obsidian or GitHub.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;On-device AI on the Mac in 2026 is genuinely shippable, but the difficulty is inverted from what you'd expect: the &lt;em&gt;AI&lt;/em&gt; part (transcription quality, summary quality) mostly just works, while the production engineering — guardrail false positives, context budgeting, repetition loops, framework lifecycle quirks, graceful degradation — is where the real work lives. None of it is hard once you know it's coming. Now you do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I built this for &lt;a href="https://apps.apple.com/us/app/reel-video-player/id6779986647" rel="noopener noreferrer"&gt;Reel&lt;/a&gt;, a local video player for macOS, available on the Mac App Store. The companion piece — getting libmpv itself through App Store review — is &lt;a href="https://dev.to/yamayu-dev/shipping-a-libmpv-based-video-player-on-the-mac-app-store-1f2l"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>macos</category>
      <category>swift</category>
      <category>apple</category>
    </item>
    <item>
      <title>Shipping a libmpv-based video player on the Mac App Store</title>
      <dc:creator>yamayu-dev</dc:creator>
      <pubDate>Thu, 02 Jul 2026 22:53:25 +0000</pubDate>
      <link>https://dev.to/yamayu-dev/shipping-a-libmpv-based-video-player-on-the-mac-app-store-1f2l</link>
      <guid>https://dev.to/yamayu-dev/shipping-a-libmpv-based-video-player-on-the-mac-app-store-1f2l</guid>
      <description>&lt;h1&gt;
  
  
  Shipping a libmpv-based video player on the Mac App Store
&lt;/h1&gt;

&lt;p&gt;Most well-known mpv-based Mac players, including IINA, distribute outside the Mac App Store — a store build has been an &lt;a href="https://github.com/iina/iina/issues/2152" rel="noopener noreferrer"&gt;open request for IINA for years&lt;/a&gt;. When I built &lt;strong&gt;Reel&lt;/strong&gt;, a local-video player/library app for macOS, I wanted to know what makes that true, and whether it could be done anyway. It can. It took a month from first commit to approval, and almost none of the hard parts were about playing video.&lt;/p&gt;

&lt;p&gt;This is a field report of every wall I hit: a JIT crash that only happens under the App Store's rules, LGPL compliance with static linking, two sandbox traps that pass locally and fail later, and one design rejection. If you're putting any FFmpeg/libmpv-family library into a sandboxed Mac app, this should save you a week or two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; Swift / SwiftUI app. Playback is AVFoundation plus &lt;strong&gt;libmpv&lt;/strong&gt; (via &lt;a href="https://github.com/mpvkit/MPVKit" rel="noopener noreferrer"&gt;MPVKit&lt;/a&gt;, statically linked, LGPL build) for containers where AVFoundation isn't enough, such as mkv, webm, and avi. Distribution: Mac App Store, sandboxed, with StoreKit 2 for a tip jar.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 1: LuaJIT inside libmpv gets your process killed — only on the App Store build
&lt;/h2&gt;

&lt;p&gt;During development (sandbox off), libmpv worked perfectly. The moment I built with the Mac App Store configuration — App Sandbox plus Hardened-Runtime-style entitlement restrictions — the process died &lt;strong&gt;inside &lt;code&gt;mpv_initialize()&lt;/code&gt;&lt;/strong&gt;, before playing anything.&lt;/p&gt;

&lt;p&gt;The culprit is the pair of JIT entitlements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;com.apple.security.cs.allow-jit&lt;/code&gt; — &lt;strong&gt;allowed&lt;/strong&gt; on the Mac App Store&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;com.apple.security.cs.allow-unsigned-executable-memory&lt;/code&gt; — &lt;strong&gt;not allowed&lt;/strong&gt; on the Mac App Store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;libmpv statically links &lt;strong&gt;LuaJIT&lt;/strong&gt;, and LuaJIT's machine-code allocator requests plain RWX executable memory rather than &lt;code&gt;MAP_JIT&lt;/code&gt; memory. With only &lt;code&gt;allow-jit&lt;/code&gt;, AMFI kills the process.&lt;/p&gt;

&lt;p&gt;I confirmed it with an A/B test on the actual signed builds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entitlements on the binary&lt;/th&gt;
&lt;th&gt;&lt;code&gt;mpv_initialize()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;allow-jit&lt;/code&gt; only (the most MAS permits)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SIGKILL (exit 137)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AMFI kills the process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;allow-jit&lt;/code&gt; + &lt;code&gt;allow-unsigned-executable-memory&lt;/code&gt; (dev config)&lt;/td&gt;
&lt;td&gt;succeeds, playback works&lt;/td&gt;
&lt;td&gt;fine — but unshippable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  "Just disable Lua scripts" does not work
&lt;/h3&gt;

&lt;p&gt;My first hope was configuration: &lt;code&gt;load-scripts=no&lt;/code&gt;, &lt;code&gt;osc=no&lt;/code&gt;, &lt;code&gt;ytdl=no&lt;/code&gt;, &lt;code&gt;config=no&lt;/code&gt;. &lt;strong&gt;No effect.&lt;/strong&gt; LuaJIT's allocator grabs executable memory during init regardless of whether any script will ever run. If LuaJIT is linked in, you crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: rebuild libmpv with Lua disabled
&lt;/h3&gt;

&lt;p&gt;Reel doesn't use mpv's Lua scripting at all — no OSC, no user scripts. So the fix was a one-line change to the mpv build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- -Dlua=luajit
&lt;/span&gt;&lt;span class="gi"&gt;+ -Dlua=disabled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A script applies this and rebuilds &lt;code&gt;Libmpv.xcframework&lt;/code&gt;. Verification:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;libmpv build&lt;/th&gt;
&lt;th&gt;Entitlements&lt;/th&gt;
&lt;th&gt;&lt;code&gt;mpv_initialize()&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;standard (LuaJIT)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allow-jit&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;SIGKILL 137&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lua disabled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allow-jit&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;succeeds, playback works&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Lua symbols in the rebuilt xcframework: &lt;strong&gt;0&lt;/strong&gt; (previously ~50 undefined LuaJIT references)&lt;/li&gt;
&lt;li&gt;All the mpv API I use (&lt;code&gt;mpv_create&lt;/code&gt;, &lt;code&gt;mpv_initialize&lt;/code&gt;, &lt;code&gt;mpv_render_context_create&lt;/code&gt;, …) intact — zero functional loss for this app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A pleasant side effect: dropping LuaJIT removed the need for &lt;code&gt;allow-unsigned-executable-memory&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;disable-library-validation&lt;/code&gt; entirely, so the final signature is tighter than the dev build ever was. The shipping entitlements file is now just: &lt;code&gt;app-sandbox&lt;/code&gt;, &lt;code&gt;network.client&lt;/code&gt; (StoreKit), &lt;code&gt;files.user-selected.read-only&lt;/code&gt;, &lt;code&gt;files.bookmarks.app-scope&lt;/code&gt;, and &lt;code&gt;cs.allow-jit&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if you statically link any library that JITs (LuaJIT, some JS engines, …), test against App Store entitlement rules &lt;em&gt;first&lt;/em&gt;. And ask whether you actually use the feature that needs the JIT — the cheapest fix may be compiling it out.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Wall 2: LGPL compliance with static linking
&lt;/h2&gt;

&lt;p&gt;libmpv and its FFmpeg dependencies are &lt;strong&gt;LGPL&lt;/strong&gt; (I use MPVKit's LGPL variant — no GPL components, no x264/x265, so there's no App Store incompatibility at the license level). But static linking triggers the LGPL's re-linking obligation: users must be able to relink the application against a modified version of the library.&lt;/p&gt;

&lt;p&gt;There are three classic ways to satisfy it. Dynamic linking doesn't get you anything on the Mac App Store (users can't swap a dylib inside a signed, store-installed bundle anyway) and would have meant restructuring the build, so I went with a &lt;strong&gt;written offer&lt;/strong&gt;, valid for three years:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An in-app &lt;strong&gt;Licenses&lt;/strong&gt; window (App menu → Licenses…) listing every library, its license, and upstream source URL, with the full LGPL texts bundled in &lt;code&gt;Reel.app/Contents/Resources&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The written offer text included in the App Store description's notes: on request, I provide the machine-readable object code of the app plus linked libraries, sufficient to relink against a user-modified library&lt;/li&gt;
&lt;li&gt;A repo script that reproduces my &lt;strong&gt;only&lt;/strong&gt; modification to the libraries (&lt;code&gt;-Dlua=disabled&lt;/code&gt;), and another script that collects the &lt;code&gt;.o&lt;/code&gt; files and link inputs from the exact release build into an archive for anyone who requests it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not legal advice — this is the reading I implemented, and it passed review. The parts most people forget: keep the &lt;em&gt;build inputs of the shipped release&lt;/em&gt; around for the duration of the offer, and remember the offer must ship with the binary (store listing + in-app), not just sit in your repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 3: two sandbox traps that pass locally and fail later
&lt;/h2&gt;

&lt;p&gt;Playing files the user picked, across relaunches, needs security-scoped bookmarks. The happy path is well documented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;NSOpenPanel&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSecurityScope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;launch&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nf"&gt;startAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The traps are not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap A: the bookmark entitlement key has a decoy spelling
&lt;/h3&gt;

&lt;p&gt;The correct key is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;com.apple.security.files.bookmarks.app-scope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variant &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;files.&lt;/code&gt; (&lt;code&gt;com.apple.security.bookmarks.app-scope&lt;/code&gt;) &lt;strong&gt;signs and runs locally just fine&lt;/strong&gt; — and is then rejected by App Store submission validation as an unsupported entitlement. Everything works on your machine, so nothing warns you until you upload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap B: a dropped folder's security scope dies with the closure
&lt;/h3&gt;

&lt;p&gt;Shipped in 1.0, added folder drag-and-drop in 1.1, and hit this: opening a folder from Finder/Dock (&lt;code&gt;.onOpenURL&lt;/code&gt;) worked, but &lt;strong&gt;dropping the same folder onto the window silently did nothing&lt;/strong&gt; — drop highlight appears, nothing imports.&lt;/p&gt;

&lt;p&gt;Debug logging showed the URL &lt;em&gt;was&lt;/em&gt; arriving. The failure chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NSItemProvider.loadObject(ofClass: URL.self)&lt;/code&gt; → returns nil for Finder file URLs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loadItem&lt;/code&gt; + &lt;code&gt;URL(dataRepresentation:)&lt;/code&gt; → yields a URL &lt;strong&gt;without&lt;/strong&gt; a security scope; &lt;code&gt;startAccessingSecurityScopedResource()&lt;/code&gt; returns false and a later &lt;code&gt;fileExists&lt;/code&gt; check quietly fails&lt;/li&gt;
&lt;li&gt;Root cause: the scoped URL from &lt;code&gt;loadInPlaceFileRepresentation&lt;/code&gt; is &lt;strong&gt;only valid until that closure returns&lt;/strong&gt;. I was hopping to the main actor with &lt;code&gt;Task { @MainActor in … }&lt;/code&gt; — by the time the task ran, the scope was gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix: mint the security-scoped bookmark &lt;em&gt;inside&lt;/em&gt; the closure, while the scope is alive, and pass the bookmark bytes onward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadInPlaceFileRepresentation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forTypeIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UTType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// The scope is alive *now* — capture it as a bookmark before leaving the closure.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bookmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSecurityScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                         &lt;span class="nv"&gt;includingResourceValuesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;@MainActor&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importDroppedBookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookmark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetry that makes this hard to spot: URLs from &lt;code&gt;.onOpenURL&lt;/code&gt; come via LaunchServices and their scope persists, so the "same" feature works through one door and not the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 4: App Review — Guideline 4, the un-reopenable main window
&lt;/h2&gt;

&lt;p&gt;First 1.0 submission was rejected under &lt;strong&gt;Guideline 4 (Design)&lt;/strong&gt;: close the main window with the red button and there's no menu item to get it back. Fair.&lt;/p&gt;

&lt;p&gt;The fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Made the main window a dedicated single &lt;code&gt;Window&lt;/code&gt; scene (not &lt;code&gt;WindowGroup&lt;/code&gt;), so the system lists it in the Window menu and reopening works&lt;/li&gt;
&lt;li&gt;Added an explicit &lt;strong&gt;Window → Main Window (⌘0)&lt;/strong&gt; item that also brings it forward when a player window covers it&lt;/li&gt;
&lt;li&gt;The app keeps its menu bar alive with the main window closed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the resubmission notes I wrote exactly what changed and how I verified it ("clean build; close main window with the red button; ⌘0 reopens it"). Plain reproduction steps, no argument. Approved.&lt;/p&gt;

&lt;p&gt;One postscript: an older branch still had the &lt;code&gt;WindowGroup&lt;/code&gt; implementation, and I later nearly merged it back over the reviewed fix. Whatever implementation passed review is the canonical one — guard it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Smaller things that also cost time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't attach a new build to an approved version.&lt;/strong&gt; New changes require a new version number. I learned this trying to add drag-and-drop to the already-approved 1.0 → it shipped as 1.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Store screenshots must have no alpha channel&lt;/strong&gt; (macOS: 2880×1800). Screen captures have alpha; converting to JPEG kills it reliably. Also, a plain UI dump looks like nothing at search-result thumbnail size — I compose the first screenshot (headline, rounded corners, shadow, dark background) with a small Python/PIL script over real captures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String Catalogs:&lt;/strong&gt; only strings that go through &lt;code&gt;LocalizedStringKey&lt;/code&gt; are auto-extracted, and &lt;code&gt;xcodebuild&lt;/code&gt; won't write keys back into the catalog for you. Budget manual passes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The checklist I wish I'd had
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Statically linking anything that JITs? Test &lt;code&gt;mpv_initialize()&lt;/code&gt; (or equivalent) under &lt;strong&gt;App Store entitlements&lt;/strong&gt;, not your dev entitlements. &lt;code&gt;allow-unsigned-executable-memory&lt;/code&gt; is not available to you.&lt;/li&gt;
&lt;li&gt;Do you actually &lt;em&gt;use&lt;/em&gt; the JIT-dependent feature? Compiling it out may be the whole fix.&lt;/li&gt;
&lt;li&gt;LGPL + static linking → written offer + relink materials + bundled license texts, and keep the release's build inputs.&lt;/li&gt;
&lt;li&gt;Bookmark entitlement key is &lt;code&gt;com.apple.security.files.bookmarks.app-scope&lt;/code&gt; — the &lt;code&gt;files.&lt;/code&gt;-less spelling only fails at submission.&lt;/li&gt;
&lt;li&gt;Security-scoped URLs from drag-and-drop die with the provider closure — bookmark them in place.&lt;/li&gt;
&lt;li&gt;Close your main window with the red button. Can a reviewer get it back from the menu bar?&lt;/li&gt;
&lt;li&gt;Approved version = new build needs a new version number.&lt;/li&gt;
&lt;li&gt;Screenshots: no alpha, and make the first one legible at thumbnail size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building the player was the easy part. Getting it — safely, license-compliantly, review-provenly — into people's hands is where the month went. But the walls are all climbable, documented above, and mostly one-time costs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reel, a local video player for macOS, is available on the &lt;a href="https://apps.apple.com/us/app/reel-video-player/id6779986647" rel="noopener noreferrer"&gt;Mac App Store&lt;/a&gt;. Happy to answer questions about any of the above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The companion piece — building the on-device Video Notes pipeline (SpeechAnalyzer + Foundation Models) on top of this player — is &lt;a href="https://dev.to/yamayu-dev/building-on-device-video-notes-in-a-macos-app-g9a"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>appstore</category>
      <category>libmpv</category>
      <category>macos</category>
    </item>
  </channel>
</rss>
