😽

情報を探索しやすくするサイトを作った

2024/09/25に公開

はじめに

グラフの表示の部分が死ぬほど大変だったので自慢したい
グラフの描画

なぜ作ったか

以前、私はこういうサイトを作りました
https://zenn.dev/oumugai/articles/56229237ab5cab
ありがたいことにユーザーが一気に増え、使ってもらえたのですが、友人に使った感じを聞くと以下のように返答がありました。
「問題集の生成はいいんだけどね~」

そこで自分で要因を分析した結果、知識や物事についての情報が可視化されているのがいいところではないかと考えました。
そこで知識の階層構造をグラフで表示するサイトを作ろうと考えたわけです。

できたもの

https://knotix.vercel.app/

どういうサービス?

サイトを見て…
というのは冗談で先程書いた通り知識を階層構造で表示してくれるサイトです。
階層構造の生成にはOpenAI APIを、グラフの表示は自前で行っています。
また履歴の保存や右クリックでのノードの削除、削除したノードからのパーソナライズ、todoリストの作成など様々な機能があります。ぜひ使ってみてください

技術的な話

グラフの表示

グラフの表示は自前で行っています。
これはクリックの処理や描画アルゴリズムの実装を楽にしたいという考えからです。(結果楽になったかはお察しです)
今回は各ノードをdiv要素、edgeをsvgを使用して描画しました。
ノードの位置決定には力学モデルを使用しました。

const simulateForces = useCallback(() => {
    const container = containerRef.current;
    if (!container || nodes.length === 0) return;

    const containerWidth = container.offsetWidth;
    const containerHeight = container.clientHeight - container.offsetTop;

    const epsilon = 0.005;
    const newNodes = nodes.map(node => ({ ...node, fx: 0, fy: 0 }));
    let stable = true;
    const isMobile = window.innerWidth < 768;

    let maxY = 0; // 最大のY座標を追跡

    newNodes.forEach(node => {
      if (node.isRoot) {
        node.x = containerWidth / 2
        // node.y = containerHeight / 2
        node.vx = 0
        // node.vy = 0
        return
      }
      newNodes.forEach(otherNode => {
        if (node.id !== otherNode.id) {
          const dx = node.x - otherNode.x;
          const dy = node.y - otherNode.y;
          const distance = Math.sqrt(dx * dx + dy * dy);
          if (distance > 0) {
            const force = isMobile ? 1500 / (distance * distance) : 5000 / (distance * distance);
            node.fx += (dx / distance) * force;
            node.fy += (dy / distance) * force;
          }
        }
      });

      links.forEach(link => {
        if (link.source === node.id || link.target === node.id) {
          const otherNode = newNodes.find(n => n.id === (link.source === node.id ? link.target : link.source));
          if (otherNode) {
            const dx = node.x - otherNode.x;
            const dy = node.y - otherNode.y;
            const distance = Math.sqrt(dx * dx + dy * dy);
            const force = isMobile ? (distance - 10) * 0.05 : (distance - 100) * 0.05;
            node.fx -= (dx / distance) * force;
            node.fy -= (dy / distance) * force;
          }
        }
      });

      const prevX = node.x;
      const prevY = node.y;

      node.vx = (node.vx + node.fx) * 0.5;
      node.vy = (node.vy + node.fy) * 0.5;
      node.x += node.vx;
      node.y += node.vy;

      node.x = Math.max(node.size / 2, Math.min(containerWidth - node.size / 2, node.x));
      node.y = Math.max(node.size / 2, Math.min(containerHeight - node.size / 2, node.y));

      // 最大のY座標を更新
      if (node.y > maxY) {
        maxY = node.y;
      }

      if (Math.abs(node.x - prevX) > epsilon || Math.abs(node.y - prevY) > epsilon) {
        stable = false;
      }
    });

    if (stable) {
      setIsStable(true);
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    } else {
      setIsStable(false);
    }
  }, [nodes, links, containerRef, onMaxYChange]);

簡単な解説を行うと各ノードの位置をバネとクーロン力2つの力で押したり引いたりして位置決定を行っています。

    newNodes.forEach(node => {
      if (node.isRoot) {
        node.x = containerWidth / 2
        // node.y = containerHeight / 2
        node.vx = 0
        // node.vy = 0
        return
      }
      newNodes.forEach(otherNode => {
        if (node.id !== otherNode.id) {
          const dx = node.x - otherNode.x;
          const dy = node.y - otherNode.y;
          const distance = Math.sqrt(dx * dx + dy * dy);
          if (distance > 0) {
            const force = isMobile ? 1500 / (distance * distance) : 5000 / (distance * distance);
            node.fx += (dx / distance) * force;
            node.fy += (dy / distance) * force;
          }
        }
      });

の部分が斥力

      links.forEach(link => {
        if (link.source === node.id || link.target === node.id) {
          const otherNode = newNodes.find(n => n.id === (link.source === node.id ? link.target : link.source));
          if (otherNode) {
            const dx = node.x - otherNode.x;
            const dy = node.y - otherNode.y;
            const distance = Math.sqrt(dx * dx + dy * dy);
            const force = isMobile ? (distance - 10) * 0.05 : (distance - 100) * 0.05;
            node.fx -= (dx / distance) * force;
            node.fy -= (dy / distance) * force;
          }
        }
      });

の部分が引力です。
定数の部分は何回か描画して実験的に決まりました。
ここでバネの計算の部分で(distance - 定数)*係数と計算していますが、この定数は近づきすぎないようにするための定数です。
また

newNodes.forEach(node => {
  if (node.isRoot) {
    node.x = containerWidth / 2
    node.vx = 0
    return
  }

はルートノードを中央に固定するために書いています。

const prevX = node.x;
const prevY = node.y;

node.vx = (node.vx + node.fx) * 0.5;
node.vy = (node.vy + node.fy) * 0.5;
node.x += node.vx;
node.y += node.vy;

node.x = Math.max(node.size / 2, Math.min(containerWidth - node.size / 2, node.x));
node.y = Math.max(node.size / 2, Math.min(containerHeight - node.size / 2, node.y));

で速度の計算を行い、コンテナの外に出ないように調整しています。
また

if (Math.abs(node.x - prevX) > epsilon || Math.abs(node.y - prevY) > epsilon) {
  stable = false;
}

はシミュレーションの終了条件です、これがないととても重くなります。
このシュミレーションのコードを何回も繰り返すことでグラフの位置を調整しています。
また今回ノードの描画にはdivを使っていますが、この方法のメリットとしてcanvasなどを使用する方法と比べクリック判定がやりやすいことが挙げられます。

他の部分

検索

検索にはDuckDuckGoのAPIを使用しています。
pythonであれば
pip install duckduckgo_search
から

from duckduckgo_search import DDGS
 with DDGS() as ddgs:
    results = list(ddgs.text(
        keywords=keyword,
        region='jp-jp',
        safesearch='off',
        timelimit=None,
        max_results=6
    ))

とするだけで簡単にweb検索を組み込むことができるようになっています。

RAG

今回、パーソナライズされたグラフ生成を行うにあたってRAGのようなことを行いました。
具体的には

  1. ノードを削除してもらう
  2. 削除したノードからベクトル検索を行う
  3. 検索結果を埋め込んでグラフ生成
    といった流れです。
    ベクトル検索のために使用したのはSupabaseです、supabaseは最近OpenAI APIなどのLLMの活用に力を入れており、SQLのデフォルトのクエリ一覧の中にOpenAI APIを活用したベクトル検索のクエリがあるくらいです。
    今回はpg.vectorを使用し、削除したnodeを保存するテーブルのカラムにvectorカラムを作成し、検索用の関数を作成し検索を行うという実装にしました。
    ベクトルDBなどを使わずにこの実装で行うメリットとしては、
  • 早く試せる
  • postgresqlであればpg vectorの拡張を入れるだけ
  • supabaseであれば簡単に導入できる
    という良さがあるように考えます。
    簡単なRAGのシステムを構築するのであればsupabaseを使用するのも手かなと思います。

最後に

まだまだ作成途中のサービスなのでフィードバックを作成者のTwitter(Xとは断固として呼ばない)のDMにどんどん送っていただけると幸いです。
またreddit
https://www.reddit.com/r/SideProject/comments/1fon2or/built_an_aipowered_tool_to_visualize_learning/
やqiita
https://qiita.com/oumugai/items/ce0e1375b7195c55aef1
のいいねやupvoteもいただけると幸いです

Discussion