asg017

Stars and mentions

Last 30 days, Oct 4 – Nov 2

GitHub stars0
mentions5
01234Oct 4Oct 18Nov 2
UTC days · repositories asg017 owns0 repositories

Package downloads

No package download history available yet.

Top repositories

asg017’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside asg017’s account where asg017 opened or reviewed pull requests in the past 365 days.

Who noticed asg017

Everyone who starred or forked asg017’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

5posts
Nov 30 posts
  1. Nulab Backlogの検索をローカルAIで拡張した — Ollama・SQLiteで意味検索を実装する 🎯 はじめに 以前、Backlogの課題とドキュメントを fzf で横断検索するCLI「bkfz」を作った。 zenn.dev しばらく使っていて、キーワード検索では見つからない場面があると分かった。「移行のあとにログインできなくなった課題があった気がする」と中身は覚えているのに、件名の言葉を思い出せないときだ。キーワード検索は、打った語が本文にそのまま含まれていないとヒットしない。 そこで、意味の近さで探す「意味検索(semantic search)」をbkfzに足した。文章をモデルで数値の列(ベクトル)に変換し、ベクトル同士の近さで候補を並べる検索だ。この変換をembeddingと呼び、この記事では「埋め込む」とも書く。embeddingはローカルの Ollama に任せた。Backlogの中身は社内情報なので、手元のマシンから外に出さない構成にしている。 hidetzu/backlog-fzf 前回の記事からの変化も書いておく。意味検索はv0.2.0で入った。同じ版で、Homebrewでの配布、URLをコピーする Ctrl-Y、TUI(端末の中で動く対話画面)を閉じずにブラウザで開く Ctrl-O も入れた。TUIで説明文や本文だけにヒットした結果が表示から消える不具合も直した。 この記事では意味検索の構成と、設計で選んだこと・選ばなかったことを書く。 ! 確認した環境は2つある。 実データでの確認、embeddingの速度、評価セットの実行: Apple M2 Max。業務のBacklogのデータはこの端末にしかない ベクトル検索のベンチマーク: Apple M2 Max(Rosetta上のGo)と、Arch Linux(Ryzen 7 5700X、ネイティブのlinux/amd64) 数値は2026年10月13日〜21日に取った。 🗺️ 全体像 既存のキーワード検索(SQLiteのFTS5による全文検索)はそのまま残し、意味検索は使う人だけが有効にする追加機能にした。主な判断は次の4つだ。 項目 選んだもの 理由 位置づけ 既存の検索を残し、意味検索は設定した人だけ有効 課題キーや固有名詞の検索はキーワード検索のほうが速く確実 embedding OpenAI互換APIを叩くだけ。既定はローカルのOllama 推論をバイナリに入れつつ、Goだけで書いた単一バイナリを保てる方法がなかった ベクトル検索 SQLiteの通常テーブルにBLOB(バイト列)で保存し、Goで総当たり 手元と同じ約1万8千ベクトルで66ms。拡張を入れるほどではない TUI 入力中はキーワード検索。Ctrl-S で今のクエリを意味検索し直す 入力のたびにembeddingを作ると、キーワード検索の即応性が落ちる 運用は「まずキーワードで打つ。出なければ Ctrl-S」という順番になる。埋め込む単位と更新の方法も、表の判断と合わせて後で見ていく。 🔥 キーワード検索で見つからないケース bkfzのキーワード検索は本文を2文字ずつ区切ったbigramの索引で候補を絞る。そのあと、元の語が本文にそのまま含まれるかを LIKE で確かめている。 この方式は課題キーや固有名詞など、文字列として覚えているものには強い。逆に、次のようなケースでは1件もヒットしない。 症状を言い換えている(件名は「〜化後のログイン不可事象」のような書き方なのに、「移行のあとにログインできなくなった」で探す) ツールの名前を思い出せない(件名はツールの固有名で、しかも綴りがゆれている) どんな言葉で書いたかを覚えていない 手元の実データで試した。直近2,500件の課題と300件のドキュメントを768次元で埋め込み、件名の語を含まない言い換えクエリを6件投げた。このときはドキュメントを分割せず、本文の先頭だけを使った。結果は目で見て判断した。 キーワード検索は6件すべてで0件だった 意味検索では、3件で意図した課題が1〜2位に来た 残る3件も、同じ種類の課題が上位5件を占めた この比較は非対話の bkfz <query> で取ったので、さきほど書いたTUIの不具合の影響は受けていない。 ! 実データは業務の情報なので、実際のクエリと件名は載せない。例はどれも抽象化したものだ。後半の評価ではリポジトリに同梱した架空データの結果を示す。 🚀 使い方 bkfz本体はHomebrewか、Releasesのバイナリで入る。v0.2.0以降が必要だ。 brew install hidetzu/tap/bkfz # macOS。Linuxは GitHub Releases のバイナリ 意味検索は既定で無効だ。設定ファイルに embedding: を書くまで、bkfzの動きは前と変わらない。 1) Ollamaを入れて、多言語対応のembeddingモデルを取得する(約600MB) brew install ollama # Linuxは ollama.com ollama pull embeddinggemma 2) 設定ファイルに追記する cat >> ~/.config/bkfz/config.yaml <<'EOF' embedding: base_url: localhost model: embeddinggemma EOF 3) 意味検索用の索引を作る(初回だけ) bkfz embed 4) 意味で探す bkfz --semantic "移行のあとにログインできなくなった" bkfz # TUIで普段どおり打ち、見つからなければ Ctrl-S TUIで Ctrl-S を押すと、プロンプトが semantic> に変わり、今のクエリで意味検索した結果に入れ替わる。続けて文字を打つと、キーワード検索に戻る。 🏗️ 設計の詳細 データの流れに沿って、埋め込む文章、embeddingの作り方、保存と検索、更新、TUIの順に見ていく。 設定しない人には何も起きない 意味検索用のテーブルは bkfz embed を初めて実行したときに作る。設定しない人のDBにはテーブルも増えないし、HTTPの通信も起きない。増えるのは Ctrl-S(と予備の Alt-S)のキーバインドだけで、押すと「設定されていない」とヘッダに表示する。 追加したテーブルは2つだ(コメントは説明のために足した)。 internal/index/vector.go CREATE TABLE IF NOT EXISTS embeddings ( kind TEXT NOT NULL, -- issue / doc ref_id INTEGER NOT NULL, chunk INTEGER NOT NULL, -- ドキュメントを分けた断片の番号 content_hash TEXT NOT NULL, vec BLOB NOT NULL, -- 長さ1に正規化したfloat32の列 PRIMARY KEY (kind, ref_id, chunk) ); CREATE TABLE IF NOT EXISTS embedding_meta ( key TEXT PRIMARY KEY, -- model / dimensions value TEXT NOT NULL ); 何を埋め込むか 埋め込む文章は課題とドキュメントで分けた。 課題: 件名と、説明の先頭1,200字で1ベクトル ドキュメント: 本文を1,000字前後の断片(chunk)に分け、1件あたり最大8本。各chunkの頭にタイトルを付ける この値は手元のデータの長さの分布から決めた。課題の説明は平均636字で、1,200字を超えるのは10,559件中1,173件だった。ドキュメントは2,799件で、本文は平均2,760字ある。設計時には1,000字で区切ると平均2.3本と見積もった。実際に作った索引は課題とドキュメントを合わせて17,949ベクトルになった。 ステータス、担当者、日付は埋め込まない。これらはキーワード検索や絞り込みで扱うほうが確実だ。 embeddingはOllamaに任せる bkfzはGoだけで書いた単一バイナリで配っている。CGO(GoからCのコードを呼ぶ仕組み)は使っていない。モデルの推論をバイナリに入れる方法も調べたが、この条件を保てるものがなかった。 ONNX Runtime(機械学習モデルの実行環境)は共有ライブラリかCGOが必要になる 純Goの推論ライブラリは、調べた範囲では依存が大きく遅い Model2Vec(文脈を見ない静的な埋め込み)は純Goで書けそうだが、Goの実装がなく、品質も劣る そこで、bkfz側はOpenAI互換の /v1/embeddings を叩くクライアントを1本持つだけにした。既定の接続先はローカルのOllamaで、モデルは多言語対応の embeddinggemma を使う。1つの文章を768個の数値(768次元)のベクトルにするモデルだ。同じAPIに対応したLM Studioやllama.cppのサーバにも差し替えられる作りにしてある。動作を確かめたのはOllamaだけだ。 クラウドのAPIも設定すれば使えるが、既定にはしなかった。社内の情報を外部へ送る動作は既定にできない。base_url がlocalhost以外のときは、課題とドキュメントを埋め込む前に注意を表示する。検索クエリを送るときには表示しない。 ベクトル検索は純Goの総当たり SQLiteでベクトル検索をするなら、拡張の sqlite-vec を使う手がある。modernc.org/sqlite のv1.60.1以降には同梱されている。bkfzが使うv1.50.0から上げれば、import 1行で使える(バイナリは0.3MB増える)。それでも今回は通常のテーブルに入れたBLOBをGoで総当たりする方式にした。 先にベンチマークを取った。ランダムに作ったベクトルをDBに入れ、新しいプロセスがDBを開いて上位50件を返すまでの時間を測る。値は3回の中央値だ。 ベクトル数 × 次元 Goで総当たり(M2 Max / Linux) sqlite-vec(M2 Max / Linux) 15,000 × 768 70ms / 56ms 25ms / 24ms 17,949 × 768 - / 66ms - / 28ms 15,000 × 256 35ms / 28ms 10ms / 10ms 50,000 × 768 211ms / 180ms 78ms / 74ms 100,000 × 256 192ms / 173ms 58ms / 58ms M2 MaxはRosetta上のGoで測ったので、総当たりの値がネイティブより遅く出ている。Linuxはネイティブだ。どちらの環境でもsqlite-vecは2〜3倍速い。 それでも、手元の実データと同じ17,949×768では総当たりで66msだった。モデルがメモリに載っている状態なら、クエリのembeddingは27〜65msだった。合計は100〜140ms程度になる見込みで、Ctrl-S を押してから待つ時間としては気にならない。 一方でsqlite-vecはv1前で、DELETE の挙動が変わるなど破壊的な変更の前例がある。専用の仮想テーブルを使うので、スキーマも複雑になる。SQLite公式のベクトル拡張(Vec1)も調べたが、2026年10月の調査時点で未リリースだった。Goから使うにはSQLiteドライバの入れ替えも必要になる。 総当たりの中身は単純だ。保存したベクトルを全部読み、クエリとの内積を取る。ベクトルは保存時に長さ1へ正規化しているので、内積がそのままコサイン類似度(向きの近さ)になる。 internal/index/vector.go for rows.Next() { // ... decodeInto(buf, blob) var s float32 for i, q := range query { s += q * buf[i] } kk := key{kind, id} if cur, seen := best[kk]; !seen || s > cur.Score { best[kk] = VectorHit{Kind: kind, RefID: id, Chunk: chunk, Score: s} } } 課題は1件1ベクトルだが、ドキュメントは複数のchunkを持つ。1件ごとに最も高いスコアだけを残して、上位k件を返す。5万ベクトルを超えたら、同じメソッドの組をsqlite-vec版に差し替えることを考える。 差分だけ埋め込み直す モデルの違いは2か所で見ている。 embedding_meta にはベクトルを作ったモデル名と次元数を記録する。設定と食い違っていたら、検索の前に bkfz embed --rebuild を促すエラーを出す。別のモデルで作ったベクトル同士を比べても、意味のある結果にならない content_hash にはモデル名と埋め込む文章をつないだsha256を保存する。同期のたびにハッシュを比べ、本文が変わった課題とドキュメントだけを埋め込み直す 初回の索引作りは bkfz embed を明示的に実行する形にした。手元のデータだと30分ほどかかる見込みだ。バッチごとにコミットするので、途中で止めても続きから再開できる。 これを bkfz sync に載せると、embedding: を書いた直後のsyncが急に長くなる。Ollamaが止まっているときにsync全体が失敗するのも避けたかった。そこで2回目以降は bkfz sync の最後で差分だけを埋め込み、Ollamaに接続できなくても課題とドキュメントの同期は成功扱いにして、警告だけを出す。 TUIはCtrl-Sのワンショット 入力のたびに意味検索する案も考えた。fzfは新しい reload が来ると、実行中の古い reload を止める。fzfの src/core.go の EvtSearchNew でこの動きを確かめた。入力が止まるまで少し待ってから検索する処理(debounce)を入れれば、打つたびの検索も成り立つ。 それでも最初の版では Ctrl-S を押したときだけ意味検索する形にした。キーワード検索の即応性を一切落としたくなかったからだ。fzfには次のような引数を渡している。 fzfに渡す引数(抜粋) --disabled --bind 'change:reload(bkfz --list {q})+change-prompt(> )+change-header:<キーの説明>' --bind 'ctrl-s:reload(bkfz --list --semantic {q})+change-prompt(semantic> )+change-header:<キーの説明>' --bind 'alt-s:(ctrl-sと同じ)' --disabled はfzf自身の絞り込みを止める指定だ。bkfzが返した結果をfzfが表示行で絞り込み直すと、本文だけにヒットした結果が消える。前回の版の不具合はこれだった。意味検索の結果は件名に語を含まないことが多いので、なおさら外せない。 alt-s は予備だ。端末によっては Ctrl-S がXON/XOFF(端末の出力を止める制御)に取られる。 Ollamaが止まっているときの見せ方にも手を入れた。fzfの中で標準エラーに書くと画面が崩れるので、--list --semantic は失敗しても終了コード0で終わる。代わりに、一覧に ✗ embedding server unreachable のような1行を出す。 📊 評価セットで既定値を決める 実データでの確認は6クエリを目で見ただけだった。これではモデルや設定を変えたときに良くなったのか悪くなったのか分からない。そこで、架空データ(課題19件、ドキュメント6件)に対する評価セットをリポジトリに入れた。 scripts/eval/cases.go {"Safari でシングルサインオンに失敗する", []string{issue("WEB-142")}}, // ... {"件数が多いと一覧の表示が重い", []string{issue("WEB-120")}}, // ... {"リリース作業の手順が書いてある資料", []string{doc(docDeployGuide), issue("OPS-57")}}, // ... {"users cannot sign in with single sign-on", []string{issue("WEB-142")}}, 言い換えクエリは22件ある。件名の言い回しをそのまま使わず、キーワード検索では正解が返らないように書いた。英語のクエリで日本語の課題を探すケースも入れている。正解が2つあるクエリは、どちらか一方が入れば当たりとする。 make eval で、Recall@k(上位k件に正解が入ったクエリの割合)とMRR(正解の順位の逆数の平均)を出す。 設定 Recall@1 Recall@5 MRR キーワード検索が正解を返した数 embeddinggemma 768次元(既定) 0.95 1.00 0.98 0 / 22 embeddinggemma 256次元 0.95 0.95 0.96 0 / 22 1位を逃した1件は「フロントエンドのフレームワークを最新版に上げる」だった。正解の「Upgrade React to v19」は2位で、1位とのスコア差は0.002だった。 この評価には限界がある。候補が25件しかなく、上位5件は全体の2割にあたる。クエリも、正解を知っている自分が書いた。点数の高さより、設定を変えたときに下がらないかを見る目安として使っている。 256次元から768次元に戻した embeddinggemmaはベクトルの先頭だけを使って次元を減らせる(Matryoshka表現)。256次元にすると索引は約52MBから17MBに減る。設計の段階ではこの256次元を既定にするつもりだった。 ところが実データの6クエリを256次元で埋め込み直すと、意図した課題が上位5件に入った数が5件から4件に減った。落ちたのは、ツールの固有名を一般語で言い換えたクエリだ。架空データでもRecall@5が1.00から0.95に下がった(22件中1件の差)。そこで既定は削らない768次元に戻し、256次元は索引の大きさを優先したい人向けの設定にした。 キーワード検索との融合(hybrid) 固有名詞を1つだけ覚えているケースなら、キーワード検索と意味検索を混ぜたほうが良いかもしれない。そこで2つの結果をRRF(Reciprocal Rank Fusion)で融合する --hybrid も実装した。各一覧での順位に定数60を足し、その逆数を合計して並べ直す方法だ。 評価セットにも「固有名詞+あいまいな説明」のクエリを12件足した(「PKCE を入れたい」「FCM の通知が重複」など)。ただ、意味検索だけで12件すべてが1位になり、hybridが上回る余地がなかった。キーワード検索のほうは、空白で区切った全部の語が本文にあることを求める。「PKCE を入れたい」の「を入れたい」が本文にないので、12件中1件しか正解を返さない。実データの約1万8千ベクトルでも、固有名詞を含む4クエリで上位3件が一致した。 このセットでは優劣を判別できなかったので、Ctrl-S の既定は単純な意味検索のままにした。設定の embedding.tui_mode: hybrid で切り替えられる。 ⚖️ トレードオフ 有効にしたときのコスト 意味検索を有効にすると、次のコストがかかる。 項目 値 初回の索引作り 30分ほどの見込み(M2 Maxで測った毎秒9.7件からの換算)。中断しても再開できる DBの増加 約52MB(768次元) Ollamaの常駐メモリ 673MB(既定では最後の利用から5分で解放) 1回の検索 100〜140ms程度の見込み(モデルがメモリに載っている状態) 5分以上空けた直後の検索 モデルの読み込みで数百ms〜数秒が加わる 設定しない人はこのどれも払わない。「設定した人だけがコストを払う」形にできたのが、今回いちばん気をつけた点だ。 クラウドのembedding APIを使えば、Ollamaの導入は要らず、初回の索引作りも数分に縮む(OpenAIのtext-embedding-3-smallで約0.13ドルの見積もり)。その代わり、Backlogの課題とドキュメントの本文がそのAPIへ送られる。業務で使うなら、組織の方針を先に確かめてほしい。 選ばなかった案と見直す条件 選ばなかった案 今回見送った理由 見直す条件 sqlite-vec v1前で、スキーマも複雑になる 5万ベクトルを超えたとき、属性で絞り込みたくなったとき Vec1(SQLite公式) 未リリースで、ドライバの入れ替えが必要 正式リリース後 推論をバイナリに内蔵 Goだけの単一バイナリを保てない Model2VecなどのGo実装が出たとき 入力のたびに意味検索 キーワード検索の即応性が落ちる debounceを入れた別モードとして 256次元を既定に 固有名詞の言い換えで取りこぼした 索引の大きさが問題になったとき(設定で選べる) hybridを既定に 評価セットで差が出なかった 実データ規模の評価で差が出たとき 🌱 次にやること 前回の記事で挙げた項目のうち、Ctrl-Y でのURLコピーはv0.2.0で入った。コメントの検索、複数space、添付ファイル本文の検索、バックグラウンド同期はまだだ。 意味検索の側では、次の項目が残っている。 コメントの同期と埋め込み(キーワード検索のコメント対応と一緒に入れる) 入力のたびに意味検索するモード 5万ベクトルを超えたときのsqlite-vecへの差し替え int8量子化(数値を8bitの整数に丸めて保存する)。索引は1/4になり、Linuxのベンチでは17,949×768の総当たりが66msから46msになった。品質への影響は評価セットで測る いちばん大きいのは評価の穴だ。今の架空ドキュメントは短く、chunkに分かれない。1,000字で区切って最大スコアを取る方式が良いのかどうかは、まだ測れていない。hybridも候補25件のセットでは比べられなかった。次の記事では長い架空ドキュメントを足した評価セットで、chunkの分け方とhybridの効果を測る。 👋 おわりに 意味検索はキーワード検索を置き換えるものではなかった。課題キーや固有名詞はキーワード検索のほうが速く確実で、意味検索が役に立つのは言葉を思い出せないときだ。だから入力中はキーワード検索、見つからないときだけ Ctrl-S という分け方に落ち着いた。 ベクトルの保存と検索も、拡張を入れる前に測った。約1万8千ベクトルなら、SQLiteのBLOBとGoの総当たりで66msだった。拡張を入れるのは、件数が増えて足りなくなってからでよい。 hidetzu/backlog-fzf

  2. There exists asg017/sqlite-vec And for small enough vector sets, holding them in memory is reasonable. Most personal photo collections would fit that scale.

  3. RAG in One .db File: node:sqlite Plus sqlite-vec, No Vector Database - Book: AI That Reads - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You have a docs/ folder with 300 Markdown files and someone asks for "chat with our docs". The usual first sketch has a hosted vector database or a Postgres instance with an extension, plus an ingestion service to feed it. For a few hundred files, that is a lot of infrastructure to answer "where do we configure the retry budget?" Node already ships a SQLite driver. Since v22.13.0 and v23.5.0 it can load SQLite extensions, and sqlite-vec is an extension that adds a vec0 virtual table for vector search. Put them together and the whole retrieval side of a RAG pipeline lives in one rag.db file next to your script. No separate vector service, and nothing to compile. One TypeScript file covers the whole loop, from indexing a folder of Markdown to an answer that cites its source files. It holds up into the hundreds of thousands of vectors, the practical ceiling sqlite-vec's author gives for its brute-force search. What you need, by version node:sqlite appeared in v22.5.0 behind --experimental-sqlite, lost the flag in v23.4.0 and v22.13.0, and became a release candidate (stability 1.2) in v25.7.0. The loadExtension() method and the allowExtension constructor option are what make sqlite-vec possible: loadExtension() only works if the database was constructed with allowExtension: true. One naming change to know about: in v26.11.0 the class DatabaseSync was renamed to Database, and DatabaseSync stays as a documentation-only deprecated alias (DEP0210). The script below imports DatabaseSync so it runs on Node 24 and 26 alike. On 26.11+ you can switch the import to Database with no other change. The script is a .ts file run directly by node. Type stripping is on by default since v23.6.0 and v22.18.0 and is marked stable since v25.2.0 and v24.12.0. So the floor is Node 22.18. I'd use 24, which is Active LTS and scheduled to move to maintenance on October 20, 2026, or 26, which becomes LTS on October 28, 2026, per the Node release schedule. sqlite-vec's current stable release on npm is 0.1.9. It ships prebuilt binaries as optional dependencies for macOS (x64, arm64), Linux (x64, arm64) and Windows x64, so npm install downloads a .dylib, .so or .dll instead of compiling anything. For embeddings and the answer this uses the OpenAI Node SDK with text-embedding-3-small (1,536 dimensions by default, per the embeddings guide) and gpt-6-luna, which OpenAI calls its most efficient current model. Swap in any provider; the SQLite side does not care where the floats come from. mkdir docs-rag && cd docs-rag npm init -y npm pkg set type=module npm install sqlite-vec openai export OPENAI_API_KEY=... type=module matters because the script uses top-level await. !A single database file holding Markdown pages and their vectors side by side Chunks, vectors and the query all live in one file next to the script. Open the database and create the tables // rag.ts import { DatabaseSync } from "node:sqlite"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import * as sqliteVec from "sqlite-vec"; import OpenAI from "openai"; const EMBED_MODEL = "text-embedding-3-small"; const DIMS = 1536; const CHAT_MODEL = "gpt-6-luna"; const MAX_CHARS = 2000; const openai = new OpenAI(); // reads OPENAI_API_KEY const db = new DatabaseSync("rag.db", { allowExtension: true }); sqliteVec.load(db); db.enableLoadExtension(false); db.exec(` CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, path TEXT NOT NULL, heading TEXT NOT NULL, body TEXT NOT NULL ); CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( embedding float[${DIMS}] distance_metric=cosine ); `); allowExtension: true is off by default, and the Node docs note that if you leave it off you cannot turn extension loading on later. sqliteVec.load(db) resolves the platform binary and calls db.loadExtension() on it. Calling enableLoadExtension(false) straight after turns extension loading off again for this connection, so SQL that runs later cannot call load_extension() unless your code switches it back on. Two tables, joined by id. chunks is a normal table with the text and where it came from. vec_chunks is the vec0 virtual table holding one 1,536-float vector per chunk, with rowid equal to chunks.id. distance_metric=cosine switches KNN from the default L2 distance to cosine, as the KNN docs describe. vec0 also supports +aux columns if you would rather keep the text inside the virtual table; a plain table is easier to inspect with any SQLite tool. Chunk the Markdown by heading type Chunk = { path: string; heading: string; body: string }; function chunkMarkdown(path: string, text: string): Chunk[] { const chunks: Chunk[] = []; let heading = "(top)"; let lines: string[] = []; let inFence = false; const flush = () => { let cur = ""; for (const para of lines.join("\n").split(/\n\s*\n/)) { if (cur && cur.length + para.length > MAX_CHARS) { chunks.push({ path, heading, body: cur.trim() }); cur = ""; } cur += para + "\n\n"; } if (cur.trim()) chunks.push({ path, heading, body: cur.trim() }); lines = []; }; for (const line of text.split("\n")) { if (line.startsWith("`".repeat(3))) inFence = !inFence; if (!inFence && /^#{1,3} /.test(line)) { flush(); heading = line.replace(/^#+ /, "").trim(); } lines.push(line); } flush(); return chunks; } Each chunk is one section under an H1 to H3 heading. Long sections are split on blank lines into pieces of about 2,000 characters, so a paragraph is never cut in half. The inFence flag stops a # comment inside a bash code block from being read as a heading. A single paragraph longer than MAX_CHARS stays whole; text-embedding-3-small accepts up to 8,192 tokens per input (embeddings guide), so that is fine for normal docs. Finding the files is one recursive readdir: async function markdownFiles(dir: string): Promise<string[]> { const entries = await readdir(dir, { recursive: true }); return entries .filter((p) => p.endsWith(".md")) .map((p) => join(dir, p)); } Embed and store async function embed(texts: string[]): Promise<Float32Array[]> { const res = await openai.embeddings.create({ model: EMBED_MODEL, input: texts, }); return res.data .sort((a, b) => a.index - b.index) .map((d) => new Float32Array(d.embedding)); } const toBlob = (v: Float32Array) => new Uint8Array(v.buffer, v.byteOffset, v.byteLength); const embedText = (c: Chunk) => `${c.path} > ${c.heading}\n\n${c.body}`; Prepending the path and heading to the embedded text (embedText) costs a few tokens and gives the vector some context when many sections share a generic heading like "Configuration". async function index(dir: string) { const chunks: Chunk[] = []; for (const path of await markdownFiles(dir)) { const text = await readFile(path, "utf8"); chunks.push(...chunkMarkdown(path, text)); } const vectors: Float32Array[] = []; for (let i = 0; i < chunks.length; i += 64) { const batch = chunks.slice(i, i + 64).map(embedText); vectors.push(...(await embed(batch))); } const insChunk = db.prepare( "INSERT INTO chunks (path, heading, body) VALUES (?, ?, ?)", ); const insVec = db.prepare( "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)", ); db.exec("BEGIN"); try { db.exec("DELETE FROM chunks; DELETE FROM vec_chunks;"); chunks.forEach((c, i) => { const r = insChunk.run(c.path, c.heading, c.body); insVec.run(BigInt(r.lastInsertRowid), toBlob(vectors[i])); }); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); throw err; } console.log(`indexed ${chunks.length} chunks`); } Three details here come straight from sqlite-vec's own node:sqlite example. Vectors go in as raw bytes: a Float32Array wrapped in a Uint8Array, because node:sqlite binds typed arrays as BLOBs and vec0 accepts a vector as a BLOB of raw float32 values. The rowid goes in as a BigInt, which is how the official example binds it. And there is no .transaction() helper in node:sqlite, so it is BEGIN, COMMIT and ROLLBACK by hand. All network calls happen before the transaction opens. If the embeddings API fails halfway through, the old index is untouched. The trade-off is that every index run re-embeds every chunk. For a few hundred files that is a small bill; past that, store a hash per chunk and only embed what changed. Query: KNN, then a cited answer type Hit = Chunk & { distance: number }; async function ask(question: string, k = 5) { const [q] = await embed([question]); const hits = db.prepare(` WITH knn AS ( SELECT rowid, distance FROM vec_chunks WHERE embedding MATCH ? AND k = ? ) SELECT c.path, c.heading, c.body, knn.distance FROM knn JOIN chunks c ON c.id = knn.rowid ORDER BY knn.distance `).all(toBlob(q), BigInt(k)) as Hit[]; const sources = hits .map((h, i) => `[${i + 1}] ${h.path} > ${h.heading}\n${h.body}`) .join("\n\n---\n\n"); const res = await openai.responses.create({ model: CHAT_MODEL, instructions: "Answer only from the numbered sources. Cite them like " + "[2]. If the sources do not answer it, say so plainly.", input: `Sources:\n\n${sources}\n\nQuestion: ${question}`, }); console.log(res.output_text); hits.forEach((h, i) => console.log(`[${i + 1}] ${h.path} (${h.distance.toFixed(3)})`), ); } The last lines of rag.ts pick the command: const [cmd, arg] = process.argv.slice(2); if (cmd === "index") await index(arg ?? "./docs"); else if (cmd === "ask" && arg) await ask(arg); else console.log('usage: node rag.ts index <dir> | ask "<q>"'); Run it: node rag.ts index ./docs node rag.ts ask "Where do we configure the retry budget?" The WHERE embedding MATCH ? AND k = ? pair is the vec0 KNN syntax. sqlite-vec also accepts LIMIT on SQLite 3.41+, but k = ? works everywhere. The KNN runs in a CTE and joins back to chunks afterwards, so the virtual table only has to answer "which rowids are closest". The model sees numbered sources with their file paths and is told to cite them, and the script prints the paths and distances after the answer. That printout is your debugging tool. When an answer is wrong, look at the five paths first: if the right section is not among them, no prompt change will fix it. One more caution: your docs are now model input. If anyone outside your team can edit them, treat retrieved text as untrusted. !A magnifier sweeping row by row across a growing stack of vectors vec0 compares the query against every stored vector, so cost grows with the corpus. When one file stops being enough sqlite-vec's stable release does exact, brute-force search. At the v0.1.0 launch its author wrote that sqlite-vec "is currently focused on really fast brute-force vector search" and that "it just didn't make sense to include a complex ANN solution in this initial version." Every query compares against every vector. In that post's benchmarks, 100,000 float vectors at 1,536 dimensions took about 105 ms per query, and at 1 million vectors "none of the float vectors at any dimension pass the 100ms smoke test." The author's own estimate of the practical ceiling for latency-sensitive apps was "probably in the 100's of thousands depending on your dimensions/quantization techniques." Those are 2024 numbers, so read them as a rough order of magnitude and measure on your own hardware. For a docs folder that is a long runway. 300 files at a few chunks each is a few thousand vectors, far below where the scan starts to hurt. If you get closer to the limit, you have three cheap levers before migrating: 1. Ask for fewer dimensions. The -3 embedding models accept a dimensions parameter, and the same benchmark had all dimensions from 1,024 down under 75 ms at 100,000 vectors. Change DIMS to match, then re-create the table and re-index. 2. Use binary quantization. The post reports a 32x storage cut and roughly 10x faster queries for "possibly only a 5-10% loss of quality." 3. Partition. vec0 partition keys restrict a KNN query to one tenant or product, with the docs' warning to keep hundreds of vectors per key. The ANN indexes are coming: the 0.1.10 alphas add rescore and DiskANN indexes, but they are pre-releases, so I wouldn't put them under production traffic yet. Graduate to pgvector when SQLite itself becomes the limit. That means several app servers writing to the index at once (SQLite allows one writer at a time), filters joined against tables that already live in Postgres, or millions of vectors where you want an HNSW or IVFFlat index and are fine with approximate results. pgvector does exact search by default too; the index is what buys you speed at that scale. Until then, a single rag.db file is easy to copy, check in as a test fixture, ship inside a container and delete when you are done. --- If this was useful This post is the retrieval loop with nothing around it. AI That Reads is the book I wrote for what comes next in TypeScript: RAG over your own docs, embeddings, vector search, and grounding answers so the model sticks to what your sources say. If you just built rag.ts and want the full picture behind each of those steps, that is the book. ![AI That Reads — AI in TypeScript](amazon.com) It is book 2 of AI in TypeScript, five books from your first LLM call to agents in production: the whole series is here.

    1 reaction
  4. claudeが意図的に省いていたらしく、実験的機能としては最近ANNインデックスが入っていたらしい: github.com/asg017/sqlite-vec/releases

    79 views
  5. 너는 개인 메모나 문서 검색을 만들 때 어떤 설정에서 가장 오래 막혔어? 저장소 asg017/sqlite-vec AI 활용 정보 더 보기 discord.gg

    8 views
Trending awards, every completed day and week
Gold
01st place finishes
Silver
02nd place finishes
Bronze
03rd place finishes
Awarded repositories
0Owned by asg017 now

No trending awards yet

None of the repositories asg017 owns has finished in the top three of a completed trending day or week, by stars, forks or PR authors. See who is trending now.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.