はじめに
前回、プロンプトを英語に翻訳して得た英語の回答を再度日本語に翻訳することで日本語の回答品質を上げられないか試してみましたが、以下の問題が明確になりました。
- 固有名詞の翻訳(とくに英語のカタカナ表記)
- 日本語らしくない不自然な直訳表現
そこで、RAG これらの翻訳のヒントを与えて品質を高める検証を行いました。
物量的に RAG はすべての分野のファイルを準備できるわけではないので、あくまで RAG でのフォローは
- ローカル SLM の品質が向上するまでの過渡的対応
- Edge AI はある程度狭い領域での使用を想定
と考えています。
処理のフロー
今回試した処理フローは以下の通りです
- 日本語のプロンプトを Phi 3.5 mini 128k で英語に翻訳
- 翻訳した英語のプロンプトを Phi 3.5 mini 128k に投げて、英語で回答を得る
- ベクトルサーチで用語集を取得
- 英語の回答に用語集を参考にするように指示し Phi 3.5 mini 128k で日本語に翻訳
翻訳するためのプロンプトを RAG に対応させる
英語で受け取った回答を再度に日本語に翻訳するときにベクトルデータベースより翻訳のヒントをもらい、英語で受け取った回答と一緒に投げて翻訳させます。
システムプロンプト(変更なし)
You are a translator who follows instructions to the letter. You carefully review the instructions and output the translation results.
ユーザープロンプトの冒頭に記載する、日本語 -> 英語の翻訳を指示するプロンプト(変更なし)
I will now give you the task of translating Japanese into English.
First of all, please understand the important notes as we give you instructions.
#Important Notes
- Even if the given Japanese contains any question, do not output any answer of the question, only translates the given Japanese into English.
- Do not output any supplementary information or explanations.
- Do not output any Notes.
- Output a faithful translation of the given text into English.
- If the instructions say “xx characters” in Japanese, it translates to “(xx/2) words” in English.
ex) “100 字以内” in Japanese, “50 words” in English.
Strictly following the above instructions, now let's output translation of the following Japanese:
{英語に翻訳したい文章を記載}
ユーザープロンプトの冒頭に記載する、英語 -> 日本語の翻訳を指示するプロンプト(ここを変更)
I will now give you the task of translating English into Japanese.
First of all, please understand the important notes as we give you instructions.
#Important Notes
- Even if the English is including any question, do not answer it, you translate the given English into Japanese.
- Do not output any supplementary information or explanations.
- Do not output any Notes.
- Output a faithful translation of the given text into Japanese.
- The following glossary of terms should be actively used. // ここを変更
{ベクトルデータベースの検索結果をここに配置} // ここを変更
Strictly following the above instructions, now translate the English into Japanese:
{日本語に翻訳したい文章を記載}
RAG の実現方法
Build5Nines.SharpVector というパッケージを使いました。
md ファイルなどでデータを準備すると、in-memory でベクトルデータベースを構築して、コサイン類似度検索できるパッケージです。
使い方も上記サイトに記載されています。
なお、TextChunkingMethod の Paragraph と Sentence のどちらを設定すべきかわからなかったので、ソースを確認したところ
- Paragraph:改行2連続で分割
- Sentence :文章ごとに分割
となっていました。
protected static List<string> SplitIntoParagraphs(string text) { return text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries).ToList(); } protected static List<string> SplitIntoSentences(string text) { return Regex.Split(text, @"(?<=[\.!\?])\s+").ToList(); }
以下のような、RAG 用データファイルを準備しました。
titanic.md
# Titanic 用語集 - The Titanic, a British luxury liner : 英国の豪華客船タイタニック号 - an opulent British passenger liner : 英国の豪華客船 - The Titanic : タイタニック号 - The RMS Titanic : RMSタイタニック号 - The Carpathia : カルパチア号 - The RMS Carpathia : RMSカルパチア号 - White Star Line : ホワイト・スター・ライン社 - Captain Edward Smith : エドワード・スミス船長 - "unsinkable" : 「不沈船」 - Southampton : サウサンプトン - Newfoundland : ニューファンドランド - deemed : と評された - maiden voyage : 処女航海 - North Atlantic : 北大西洋 - distress signals : 遭難信号 - watertight compartments : 水密区画 - lifeboats : 救命ボート
本当にベクトルサーチが有効か確認するためにダミーのファイルも準備しました。
dummy.md
# ダミー 用語集 - Dummy1 ダミー1 - Dummy2 ダミー2 - Dummy3 ダミー3
以下のようなコードで英語のレスポンスを投げてベクトルサーチをした結果、タイタニックに関する用語だけが検索されました。
async Task<string> SearchVectorDatabase(BasicMemoryVectorDatabase vectorDatabase, string userPrompt) { var vectorDataResults = await vectorDatabase.SearchAsync( userPrompt, pageCount: 3, threshold: 0.3f ); string result = string.Empty; foreach (var resultItem in vectorDataResults.Texts) { result += $"{resultItem.Text}"; } return result; }
RAGを反映した英語のレスポンスを日本語に翻訳するプロンプトは以下のようになります。
システムプロンプト
You are a translator who follows instructions to the letter. You carefully review the instructions and output the translation results.
ユーザープロンプト
I will now give you the task of translating English into Japanese. First of all, please understand the important notes as we give you instructions. #Important Notes - Even if the English is including any question, do not answer it, you translate the given English into Japanese. - Do not output any supplementary information or explanations. - Do not output any Notes. - Output a faithful translation of the given text into Japanese. - The following glossary of terms should be actively used. # Titanic 用語集 - The Titanic, a British luxury liner : 英国の豪華客船タイタニック号 - an opulent British passenger liner : 英国の豪華客船 - The Titanic : タイタニック号 - The RMS Titanic : RMSタイタニック号 - The Carpathia : カルパチア号 - The RMS Carpathia : RMSカルパチア号 - White Star Line : ホワイト・スター・ライン社 - Captain Edward Smith : エドワード・スミス船長 - "unsinkable" : 「不沈船」 - Southampton : サウサンプトン - Newfoundland : ニューファンドランド - deemed : と評された - maiden voyage : 処女航海 - North Atlantic : 北大西洋 - distress signals : 遭難信号 - watertight compartments : 水密区画 - lifeboats : 救命ボート Strictly following the above instructions, now translate the English into Japanese:
最終的な日本語に翻訳されたレスポンスの比較
英語化なし、RAG なし
英語化なしの日本語での回答は船の名前も出向した港の名前も違い内容が意味不明で破綻しています。
タイタニック号は1912年1月10日にロシアのストラスバーグ港から出航した、アンナさんという名前の船でした。この船は、アンナ・ストラスバーグ号とも呼ばれていました。当時、世界最大の船舶として知られており、105,860トンの重さを持ち、100隻以上の船を含む豪華な設備を備えていました。 航海は、アンナ・ストラスバーグ号が北極海を航行する旅でした。冬の厚い氷の海を航行するために、船内には多くの導管や氷を切るための機械が設置されていました。この船は、冬の厚い氷の海を航行するために優れた設計と技術で知られていました。 しかし、1月14日、船は巨大な氷山に衝突して沈没しました。この事故は、船内に乗組んだ乗客および船員約1,500人の命を奪いました。この悲劇は、世界史上最大の海上事故として記録されており、その後の航海技術や安全規制に大きな影響を与えました。 タイタニック号の航海は、人類の歴史における重要な航海事件として記録されています。それは、当時の航海技術の限界と、海上事故の深刻な結果を示しています。この事件は、安全規制の強化と航海技術の進歩についての議論を促し、今日まで続いています。
英語化あり、RAG なし
英語の時点ではかなり精度が高いですが、固有名詞の翻訳品質が低く最終的な内容は微妙な感じになります。
タイタニック、英国の乗客船として「沈まない」と認識されたがゆえんの大船は、南英国のサウスアンティオンからニューヨーク市アメリカ合衆国への初航船出発日である4月10日に出発した。この船の指揮を執ったエドワード・スミス長により、乗員および乗客約2,200人を乗せていた。これらには富裕層、移住者、移住者が含まれており、船の旅はその先進的な安全機能、例えば水室と遠隔操作された船舶が含まれていた。 しかし、4月14日の夜、タイタニックは北大西洋海岸にある氷山に衝突した。この衝突により船体の背骨が破損し、前方の水室に水が溢れ始めた。船舶の乗組員が水を排除し、浮力を維持するための努力をしたが、タイタニックはわずか2時間で沈没した。 この災害は約1,500人の命を奪い、歴史上最大の平和時代の海上事故の一つとなった。タイタニックの沈没は、海上安全規則の改善をもたらし、1914年に国際海上安全規則(SOLAS)が設立された。タイタニックの残骸は1985年に12,500フィート以上の深度で発見され、それ以来、海底考古学者が探査している。タイタニックの物語は、人間の創造力の限界と自然の不確実性の暴力の物語として、永続する覚えとなっている。
英語化あり、RAG あり
RAG を導入したことで固有名詞のカタカナ表記や滑らかな日本語としての品質が向上しましたが、検証の結果以下の特徴がわかりました。
- 与える用語が多いと文章のつながりの滑らかさが失われやすい
- RAG のキーワード数を最低限に絞ることで最大限の品質が出る
タイタニック号、英国の豪華客船として「不沈船」と評された、サウサンプトンからニューヨーク市へ向かう処女航海を始めた。エドワード・スミス船長の指揮の下、この豪華客船は2,200人の乗員と乗客を乗せ、富裕層、移住者、移住者を含んでいた。タイタニック号の旅は、水密区画や遠隔操作された救命ボートなどの 先進的な安全機能によって特徴付けられていた。 しかし、1912年4月14日の夜、タイタニック号は北大西洋にある氷山に衝突した。この衝突により、船体の前部に水が溢れ、水密区画が破損した。船内の水を排水し、浮力を維持するための努力は行われたが、タイタニック号はわずか2時間で沈没した。 この災害は約1,500人の命を奪い、歴史上最大の平和海上事故の一つとなった。この事故は、船舶安全規則の改善を促し、1914年に国際沿岸安全規則(SOLAS)を設立した。タイタニック号の残骸は1985年に12,500フィート以上の深度で発見され、これまでに海底考古学者によって探査されている。タイタニック号の物語は、人間の創造力の限界と自然の不確実性の象徴として、永続する考えさせられるものである。
結論
Phi 自体が英語前提のためでの日本語での問い合わせに対する回答品質はそれほど高くありませんが、
- Phi 自体を活用してプロンプトを英語に翻訳し問い合わせを行う
- 英語のレスポンスを RAG で用語補正を行い Phi で日本語に翻訳する
という2つの戦略でローカルの SLM のみを活用しても品質をある程度上げることができました。
ローカル PC のリソースのみで Text Completion を活用するにはまだまだ課題はありますが、今回はそれなりの品質を出せたということで検証を終了としようと思います。
コード
最後に完成したソリューションですが、以下のリポジトリにあります。
github.com
appsettings.json と Program.cs だけこちらにも掲載しておきます。
appsettings.json
{
"modelPhi35Min128k": "<Your model path>",
"modelPhi3Med4k": "<Your model path>",
"modelPhi3Med128k": "<Your model path>",
"modelPhi3Min4k": "<Your model path>",
"modelPhi3Min128k": "<Your model path>",
"modelPhi4": "<Your model path>",
"modelPhi4Min128k": "<Your model path>",
"isTranslate": "true",
"isUsingRag": "true",
"systemPrompt": "あなたは船舶と航海の歴史に詳しい歴史学者です。与えられた質問にあなたの船舶と航海の歴史の知識を最大限活用して解説してください。",
"userPrompt": "タイタニック号の航海について最大300字以内で教えてください。",
"additionalDocumentsPath": "additional-docs"
Program.cs
using Microsoft.ML.OnnxRuntimeGenAI; using System.Diagnostics; using System.Text; using Build5Nines.SharpVector; using Build5Nines.SharpVector.Data; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Configuration; var newLine = Environment.NewLine; var builder = Host.CreateApplicationBuilder(args); builder.Configuration.Sources.Clear(); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) .Build(); var configuration = builder.Configuration; var modelPath = new ModelPath(builder); var prompt = new Prompt(builder); var option = new Option(builder); string additionalDocumentsPath = configuration["additionalDocumentsPath"] ?? throw new ArgumentNullException("additionalDocumentsPath is not found"); using OgaHandle ogaHandle = new OgaHandle(); // RAG 用のベクトルデータベースのセットアップ var additionalDocumentsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, additionalDocumentsPath); var vectorDatabase = new BasicMemoryVectorDatabase(); LoadAdditionalDocuments(additionalDocumentsDirectory).Wait(); Console.WriteLine(); // モデルのセットアップ Console.WriteLine($"Loading model:{newLine}{modelPath.Phi35Min128k}"); var sw = Stopwatch.StartNew(); using Model model = new Model(modelPath.Phi35Min128k); using Tokenizer tokenizer = new Tokenizer(model); sw.Stop(); Console.WriteLine($"{newLine}Model loading time is {sw.Elapsed.Seconds:0.00} sec.\n"); // 翻訳するかどうか Console.WriteLine($"翻訳する:{newLine}{option.IsTranslate}"); // RAG を使うかどうか Console.WriteLine($"RAG を使う:{newLine}{option.IsUsingRag}"); // プロンプトのセットアップ Console.WriteLine($"{newLine}システムプロンプト:{newLine}{prompt.System}"); Console.WriteLine($"{newLine}ユーザープロンプト:{newLine}{prompt.User}{newLine}"); var translatedSystemPrompt = string.Empty; if (option.IsTranslate) { Console.WriteLine("Translated System Prompt:"); await foreach (var translatedPart in Translate(prompt.System, Language.Japanese, Language.English)) { Console.Write(translatedPart); translatedSystemPrompt += translatedPart; } Console.WriteLine($"{newLine}----------------------------------------{newLine}"); } else { translatedSystemPrompt = prompt.System; } var translatedUserPrompt = string.Empty; if (option.IsTranslate) { Console.WriteLine("Translated User Prompt:"); await foreach (var translatedPart in Translate(prompt.User, Language.Japanese, Language.English)) { Console.Write(translatedPart); translatedUserPrompt += translatedPart; } Console.WriteLine($"{newLine}----------------------------------------{newLine}"); } else { translatedUserPrompt = prompt.User; } Console.WriteLine($"{newLine}システムプロンプト:{newLine}{translatedSystemPrompt}"); Console.WriteLine($"{newLine}ユーザープロンプト:{newLine}{translatedUserPrompt}{newLine}"); var sequences = tokenizer.Encode($@"<|system|>{translatedSystemPrompt}<|end|><|user|>{translatedUserPrompt}<|end|><|assistant|>"); // プロンプトを投げて回答を得る using GeneratorParams generatorParams = new GeneratorParams(model); generatorParams.SetSearchOption("min_length", 100); generatorParams.SetSearchOption("max_length", 2000); //generatorParams.SetSearchOption("temperature", 1.0); //generatorParams.SetSearchOption("top_k", 0.0); //generatorParams.SetSearchOption("top_p", 1.0); generatorParams.SetSearchOption("past_present_share_buffer", false); generatorParams.TryGraphCaptureWithMaxBatchSize(1); generatorParams.SetInputSequences(sequences); using var tokenizerStream = tokenizer.CreateStream(); using var generator = new Generator(model, generatorParams); StringBuilder stringBuilder = new(); Console.WriteLine("Response:"); var totalTokens = 0; string part; sw = Stopwatch.StartNew(); while (!generator.IsDone()) { try { await Task.Delay(50).ConfigureAwait(false); generator.ComputeLogits(); generator.GenerateNextToken(); part = tokenizerStream.Decode(generator.GetSequence(0)[^1]); Console.Write(part); stringBuilder.Append(part); if (stringBuilder.ToString().Contains("<|end|>") || stringBuilder.ToString().Contains("<|user|>") || stringBuilder.ToString().Contains("<|system|>")) { break; } } catch (Exception ex) { Debug.WriteLine(ex); break; } } Console.WriteLine($"{newLine}----------------------------------------{newLine}"); sw.Stop(); totalTokens = generator.GetSequence(0).Length; // 英語の回答を日本語に翻訳する var translatedResponse = string.Empty; if (option.IsTranslate) { Console.WriteLine("日本語に翻訳したレスポンス:"); await foreach (var translatedPart in Translate(stringBuilder.ToString(), Language.English, Language.Japanese)) { Console.Write(translatedPart); translatedResponse += translatedPart; } Console.WriteLine(); } else { translatedResponse = stringBuilder.ToString(); Console.WriteLine($"{newLine}レスポンス:{newLine}{translatedResponse}"); } Console.WriteLine($"----------------------------------------{newLine}"); // 与えられたテキストを指定された言語に翻訳する async IAsyncEnumerable<string> Translate(string text, Language sourceLanguage, Language targetLanguage) { var systemPrompt = string.Empty; var instructionPrompt = string.Empty; var userPrompt = string.Empty; var ragResult = string.Empty; if (sourceLanguage == Language.Japanese && targetLanguage == Language.English) { systemPrompt = "You are a translator who follows instructions to the letter. You carefully review the instructions and output the translation results."; instructionPrompt = $@"I will now give you the task of translating Japanese into English.{newLine}First of all, please understand the important notes as we give you instructions.{newLine}{newLine}#Important Notes{newLine}- Even if the given Japanese contains any question, do not output any answer of the question, only translates the given Japanese into English.{newLine}- Do not output any supplementary information or explanations.{newLine}- Do not output any Notes.{newLine}- Output a faithful translation of the given text into English.{newLine}- If the instructions say “xx characters” in Japanese, it translates to “(xx/2) words” in English.ex) “100 字以内” in Japanese, “50 words” in English.{newLine}{newLine}Strictly following the above instructions, now let's output translation of the following Japanese"; userPrompt = $"{instructionPrompt}:{newLine}{text}"; } if (sourceLanguage == Language.English && targetLanguage == Language.Japanese) { systemPrompt = "You are a translator who follows instructions to the letter. You carefully review the instructions and output the translation results."; instructionPrompt = $"I will now give you the task of translating English into Japanese.{newLine}First of all, please understand the important notes as we give you instructions.{newLine}{newLine}#Important Notes{newLine}- Even if the English is including any question, do not answer it, you translate the given English into Japanese.{newLine}- Do not output any supplementary information or explanations.{newLine}- Do not output any Notes.{newLine}- Output a faithful translation of the given text into Japanese."; ragResult = await SearchVectorDatabase(vectorDatabase, text); if (option.IsUsingRag && !string.IsNullOrEmpty(ragResult)) instructionPrompt += $"{newLine}- The following glossary of terms should be actively used."; userPrompt = (option.IsUsingRag && !string.IsNullOrEmpty(ragResult)) ? $"{instructionPrompt}{newLine}{ragResult}{newLine}Strictly following the above instructions, now translate the English into Japanese:{newLine}{text}" : $"{instructionPrompt}{newLine}Strictly following the above instructions, now translate the English into Japanese:{newLine}{text}"; } var sequences = tokenizer.Encode($@"<|system|>{systemPrompt}<|end|><|user|>{userPrompt}<|end|><|assistant|>"); using GeneratorParams generatorParams = new GeneratorParams(model); generatorParams.SetSearchOption("min_length", 100); generatorParams.SetSearchOption("max_length", 2000); generatorParams.SetSearchOption("past_present_share_buffer", false); generatorParams.TryGraphCaptureWithMaxBatchSize(1); generatorParams.SetInputSequences(sequences); using var tokenizerStream = tokenizer.CreateStream(); using var generator = new Generator(model, generatorParams); StringBuilder stringBuilder = new(); while (!generator.IsDone()) { string streamingPart = string.Empty; try { await Task.Delay(10).ConfigureAwait(false); generator.ComputeLogits(); generator.GenerateNextToken(); streamingPart = tokenizerStream.Decode(generator.GetSequence(0)[^1]); stringBuilder.Append(streamingPart); if (stringBuilder.ToString().Contains("<|end|>") || stringBuilder.ToString().Contains("<|user|>") || stringBuilder.ToString().Contains("<|system|>")) { break; } } catch (Exception ex) { Debug.WriteLine(ex); break; } yield return streamingPart; } } async Task LoadAdditionalDocuments(string directoryPath) { Console.WriteLine($"Loading Additional Documents:"); var files = Directory.GetFiles(directoryPath, "*.*", SearchOption.AllDirectories) .Where(f => f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".mdx", StringComparison.OrdinalIgnoreCase)).ToArray(); var vectorDataLoader = new TextDataLoader<int, string>(vectorDatabase); var tasks = files.Select(async file => { Console.WriteLine($"{file}"); if (System.IO.File.Exists(file)) { var fileContents = await System.IO.File.ReadAllTextAsync(file); await vectorDataLoader.AddDocumentAsync(fileContents, new TextChunkingOptions<string> { Method = TextChunkingMethod.Paragraph, RetrieveMetadata = (chunk) => file }); } }); await Task.WhenAll(tasks); } async Task<string> SearchVectorDatabase(BasicMemoryVectorDatabase vectorDatabase, string userPrompt) { var vectorDataResults = await vectorDatabase.SearchAsync( userPrompt, pageCount: 3, threshold: 0.3f ); string result = string.Empty; foreach (var resultItem in vectorDataResults.Texts) { result += $"{resultItem.Text}{newLine}"; } return result; } public sealed class ModelPath { private readonly string modelPhi35Min128k; private readonly string modelPhi3Med4k; private readonly string modelPhi3Med128k; private readonly string modelPhi3Min4k; private readonly string modelPhi3Min128k; private readonly string modelPhi4; private readonly string modelPhi4Min128k; public ModelPath(HostApplicationBuilder builder) { var configuration = builder.Configuration; modelPhi35Min128k = configuration["modelPhi35Min128k"] ?? throw new ArgumentNullException("modelPhi35Min128k is not found."); modelPhi3Med4k = configuration["modelPhi3Med4k"] ?? throw new ArgumentNullException("modelPhi3Med4k is not found."); modelPhi3Med128k = configuration["modelPhi3Med128k"] ?? throw new ArgumentNullException("modelPhi3Med128k is not found."); modelPhi3Min4k = configuration["modelPhi3Min4k"] ?? throw new ArgumentNullException("modelPhi3Min4k is not found."); modelPhi3Min128k = configuration["modelPhi3Min128k"] ?? throw new ArgumentNullException("modelPhi3Min128k is not found."); modelPhi4 = configuration["modelPhi4"] ?? throw new ArgumentNullException("modelPhi4 is not found."); modelPhi4Min128k = configuration["modelPhi4Min128k"] ?? throw new ArgumentNullException("modelPhi4Min128k is not found."); } public string Phi35Min128k { get => modelPhi35Min128k; } public string Phi3Med4k { get => modelPhi3Med4k; } public string Phi3Med128k { get => modelPhi3Med128k; } public string Phi3Min4k { get => modelPhi3Min4k; } public string Phi3Min128k { get => modelPhi3Min128k; } public string Phi4 { get => modelPhi4; } public string Phi4Min128k { get => modelPhi4Min128k; } } public sealed class Prompt { private readonly string systemPrompt; private readonly string userPrompt; public Prompt(HostApplicationBuilder builder) { var configuration = builder.Configuration; systemPrompt = configuration["systemPrompt"] ?? throw new ArgumentNullException("systemPrompt is not found."); userPrompt = configuration["userPrompt"] ?? throw new ArgumentNullException("userPrompt is not found."); } public string System { get => systemPrompt; } public string User { get => userPrompt; } } public sealed class Option { private readonly bool isTranslate; private readonly bool isUsingRag; public Option(HostApplicationBuilder builder) { var configuration = builder.Configuration; isTranslate = bool.TryParse(configuration["isTranslate"] ?? throw new ArgumentNullException("isTranslate is not found."), out var resultIsTranslate) && resultIsTranslate; isUsingRag = bool.TryParse(configuration["isUsingRag"] ?? throw new ArgumentNullException("isUsingRag is not found."), out var resultIsUsingRag) && resultIsUsingRag; } public bool IsTranslate { get => isTranslate; } public bool IsUsingRag { get => isUsingRag; } } public enum Language { Japanese, English }