fix(engine): 整句联想按 P(读音|字) 压住多音字的生僻读音 (#1123)

tai'zhuan'qian'le 出「大赚钱了」:大 也读 tai,词库里这一行权重 4719,占 tai 的
0.47%,已有的 reading_prior(看 P(字|拼音),1% 免罚线)只扣 0.33,翻不过语言模型
对「大赚」约 1.6 的偏好。换个角度看,大 读 tai 只占它全部权重的 0.14%——这才是
「这个字几乎不这么读」,语言模型只认汉字,这一维要由读音补上。

词格跨度查询对单字行加一项 P(读音|字) 惩罚:占比低于 10% 的部分按 log10 扣分,
上限 2.5。长 chang、重 chong、行 hang 这类常见第二读音不扣;大 tai/dai、会 kuai、
都 du、给 ji 扣 1.5~2。只罚单字,词本身就是读音的证据(会计、成都、大夫)。多音字
各读音的权重和在第一次整句联想时扫一遍单字表载入,全拼与双拼(含直接辅助码)共用。

自建 111 句多音字评测从 79 提到 82,无回归;5% 档翻不过「大赚钱了」,20% 与 10%
结果相同,取保守的一边。test_engine_smoke 加固定权重的断言,句子评测集补两条。
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-20 08:47:27 +08:00
1 parent dd018f7985
commit a60e2948db
8 files changed
+172 -7

No files matched your search

+6 -2
View File
@@ -1890,8 +1890,12 @@ void QuanpinDictionary::reset_cache()
quanpin::WordLatticeLookup QuanpinDictionary::lattice_lookup(int limit, bool with_fuzzy)
{
const auto fuzzy = with_fuzzy ? fuzzy_pinyin_ : metasequoia::FuzzyPinyinOptions{};
return [this, lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, limit, fuzzy), limit,
fuzzy](const quanpin::Segments &span) {
if (!char_reading_totals_)
{
char_reading_totals_ = quanpin::load_char_reading_totals(db_);
}
return [this, lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, limit, fuzzy, char_reading_totals_),
limit, fuzzy](const quanpin::Segments &span) {
// 超过上限整体清掉,免得一次长时间输入把内存撑大(同双拼的 direct_span_cache_)。
constexpr std::size_t kLatticeSpanCacheLimit = 8192;
std::string key = quanpin::join_segments(span);
+3
View File
@@ -204,6 +204,9 @@ class QuanpinDictionary
CircularBuffer<std::string, std::optional<quanpin::LatticePath>> lattice_best_path_cache_;
// 见 lattice_lookup。
std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> lattice_span_cache_;
// 多音单字各读音的权重和,词格第一次查跨度时载入,此后不随 reset_cache 重载。
// 见 quanpin::load_char_reading_totals。
std::shared_ptr<const quanpin::CharReadingTotals> char_reading_totals_;
sqlite3 *db_ = nullptr;
sqlite3_int64 data_version_ = -1;
metasequoia::RuntimePaths paths_;
+91 -3
View File
@@ -1286,10 +1286,96 @@ std::vector<KeyedQueryItem> query_exact_segmentations_keyed_flat(
return result;
}
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy)
std::shared_ptr<const CharReadingTotals> load_char_reading_totals(sqlite3 *db)
{
return [db, &statement_cache, span_limit, fuzzy](const Segments &span) {
auto totals = std::make_shared<CharReadingTotals>();
if (db == nullptr)
return totals;
std::vector<std::string> tables;
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(
db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'tbl\\_1\\_%' ESCAPE '\\'", -1,
&statement, nullptr) != SQLITE_OK)
return totals;
while (sqlite3_step(statement) == SQLITE_ROW)
{
const auto *name = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
if (name != nullptr)
tables.emplace_back(name);
}
sqlite3_finalize(statement);
// 先按字累计权重和读音数,再只留多音字:单读音的字占比恒为 1,存着只占内存。
std::unordered_map<std::string, std::pair<std::int64_t, int>> sums;
for (const auto &table : tables)
{
const std::string sql = "SELECT value, weight FROM " + table;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK)
continue;
while (sqlite3_step(statement) == SQLITE_ROW)
{
const auto *value = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
if (value == nullptr)
continue;
auto &[sum, readings] = sums[value];
sum += (std::max)(sqlite3_column_int64(statement, 1), sqlite3_int64{0});
++readings;
}
sqlite3_finalize(statement);
}
for (const auto &[value, sum] : sums)
{
if (sum.second > 1)
totals->emplace(value, sum.first);
}
return totals;
}
namespace
{
// P(读音|字) 的免罚线与扣分上限(log10)。
//
// 已有的 reading_prior 看的是 P(字|拼音):这一行占该拼音全部行的权重比。它分得出权重为 0 的
// 「卷」读 gun,却分不出「大」读 tai——大 的 tai 行有 4719,占 tai 的 0.47%,只扣 0.33,翻不过
// 语言模型对「大赚」的偏好(差 1.6 左右),tai'zhuan'qian'le 于是出「大赚钱了」。同一行换个
// 角度看,大 的 da 行有 327 万,读 tai 的只占 0.14%:这才是「这个字几乎不这么读」。语言模型
// 只认汉字,这一维只能由读音补上(噪声信道里的 P(拼音|字))。
//
// 免罚线定在 10%:长 chang 42%、重 chong 20%、行 hang 15% 这类常见的第二读音一分不扣,
// 觉 jiao、还 huan(8%~9%)只扣零点零几,着 zhao/zhuo、地 di(3%~4%)扣 0.4~0.5。真正的
// 生僻读音——大 tai/dai、会 kuai、都 du、给 ji——都在 0.1%~0.3%,扣 1.5~2。了 liao(0.47%)
// 扣 1.3,「受不了」「去不了」仍靠语言模型的上下文赢下 料/聊。5% 那一档还差一点翻不过
// 「大赚钱了」,20% 在评测句上与 10% 结果相同,取保守的一边。上限只管权重为 0 的读音,那些
// reading_prior 已经扣过一大截。
//
// 只罚单字:多音字的少见读音落在词里(会计、成都、大夫)时整词就是读音的证据。没有同音
// 竞争的字(得 只有它一个读 dei)各条路径扣得一样多,排序不受影响。
constexpr double kRareCharReadingShare = 0.1;
constexpr double kRareCharReadingFloor = 2.5;
double rare_char_reading_penalty(const LatticeLexeme &lexeme, const CharReadingTotals *totals)
{
if (totals == nullptr)
return 0.0;
const auto found = totals->find(lexeme.value);
if (found == totals->end() || found->second <= 0)
return 0.0;
const double weight = lexeme.weight > 0 ? static_cast<double>(lexeme.weight) : 0.0;
const double share = (weight + 1.0) / (static_cast<double>(found->second) + 1.0);
if (share >= kRareCharReadingShare)
return 0.0;
return (std::min)(-std::log10(share / kRareCharReadingShare), kRareCharReadingFloor);
}
} // namespace
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy,
std::shared_ptr<const CharReadingTotals> char_totals)
{
return [db, &statement_cache, span_limit, fuzzy, char_totals = std::move(char_totals)](const Segments &span) {
// ü 的两种写法归一到词库存的那一种。intact_pinyin_list() 两种都认,可词库只存
// 一种:切出来的 jv、lue 这类音节一行都查不到,精确键查询查空,整个词格就断在
// 这里——wo'men'lue'de 解不出任何路径,长句联想直接消失。这不是模糊音降级,lue
@@ -1320,6 +1406,8 @@ WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::st
for (const auto &row : rows)
{
LatticeLexeme lexeme{row.key, row.value, row.weight};
if (span.size() == 1)
lexeme.penalty += rare_char_reading_penalty(lexeme, char_totals.get());
if (keys.size() > 1)
{
// 每个与敲出读音不同的音节扣一份分:一个词里错两个音,比只错一个更不可能。
+12 -1
View File
@@ -5,6 +5,7 @@
#include <sqlite3.h>
#include <memory>
#include <string>
#include <unordered_map>
#include <utility>
@@ -85,13 +86,23 @@ std::vector<KeyedQueryItem> query_exact_segmentations_keyed_flat(
const std::vector<Segments> &segmentations, sqlite3 *db,
std::unordered_map<std::string, sqlite3_stmt *> &statement_cache, int limit = 128);
// 多音单字在各读音下的权重和(单字 → Σweight),只收词库里挂在两个以上读音下的字。
// 词格用它算 P(读音|字),见 make_lattice_db_lookup。
using CharReadingTotals = std::unordered_map<std::string, std::int64_t>;
// 扫一遍全部单音节表(五万行量级)。词库被学习写入时不必重载:调频改动的权重相对
// 出厂权重的量级可以忽略,这里只要分出「大」读 da 还是读 tai 这种数量级上的差别。
std::shared_ptr<const CharReadingTotals> load_char_reading_totals(sqlite3 *db);
// 词格的跨度查询:只返回键与跨度完全相等的行,没有前缀区间与简拼降级——整句
// 联想出来的每个词,读音都必须精确对上用户敲的那段拼音。因此也不需要 QuerySource:
// 区分全拼/双拼的只有简拼那几级。
// 开着模糊音时同时收跨度的模糊变体:行的 key 是词库里的真实读音,每个与敲出读音不同的音节
// 在 penalty 上记一份 kFuzzyLatticePenaltyLog。整句的 canonical_pinyin 因此落在真实读音上,
// 选中后造词不会把错音写进词库。
// 给了 char_totals 时,单字行按这个字少见的读音在 penalty 上扣分(见 quanpin_query.cpp 的
// rare_char_reading_penalty)。
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy = {});
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy = {},
std::shared_ptr<const CharReadingTotals> char_totals = nullptr);
} // namespace quanpin
+6 -1
View File
@@ -1205,7 +1205,12 @@ void ShuangpinDictionary::set_fuzzy_pinyin(metasequoia::FuzzyPinyinOptions optio
quanpin::WordLatticeLookup ShuangpinDictionary::lattice_lookup(int limit)
{
auto lookup = quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, limit, fuzzy_pinyin_);
if (!char_reading_totals_)
{
char_reading_totals_ = quanpin::load_char_reading_totals(quanpin_db_);
}
auto lookup = quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, limit, fuzzy_pinyin_,
char_reading_totals_);
if (!direct_span_cache_enabled_)
{
return lookup;
+2
View File
@@ -145,6 +145,8 @@ class ShuangpinDictionary
bool direct_span_cache_enabled_ = false;
metasequoia::FuzzyPinyinOptions fuzzy_pinyin_;
std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> direct_span_cache_;
// 见 QuanpinDictionary::char_reading_totals_。
std::shared_ptr<const quanpin::CharReadingTotals> char_reading_totals_;
void reset_cache_if_database_changed();
// 这个字能不能落在 helpcode 约束的音节上。
bool accepts_syllable_char(const SyllableHelpcode &helpcode, const std::string &hanzi) const;
@@ -30,6 +30,8 @@ xianzaijiuqu 现在就去 plain
ganxienidebangzhu 感谢你的帮助 plain
anzhaoguiding 按照规定 plain
tiananmenguangchang 天安门广场 plain
taizhuanqianle 太赚钱了 plain 大 也读 tai,但只占它全部权重的 0.14%;语言模型偏爱「大赚」,靠 P(读音|字) 压回去
womendouquguochengdu 我们都去过成都 plain 都 读 du 是生僻读音,「去过程都」靠同一条读音惩罚压回去
zaijinuta 再激怒他 plain 贪心切分在 jin 上卡死的合法输入;再/在 两可,只盯切分不回退
fangan 方案 word
danganguan 档案馆 word
1 # 全拼整句级切分评测集。eval_quanpin_sentence 逐条敲入「输入」,看首选是不是「期望首选」。
30 ganxienidebangzhu
31 anzhaoguiding
32 tiananmenguangchang
33 taizhuanqianle
34 womendouquguochengdu
35 zaijinuta
36 fangan
37 danganguan
+50
View File
@@ -3,6 +3,7 @@
#include "../../english/english_dictionary.h"
#include "../../japanese/romaji_converter.h"
#include "../../neural/neural_decoder.h"
#include "../../quanpin/quanpin_query.h"
#include "../../quanpin/word_lattice.h"
#include "../../user_dictionary/user_dictionary_journal.h"
#include "test_directory_cleanup.h"
@@ -88,6 +89,55 @@ int run_test()
throw std::runtime_error("Kana conversion changed during the platform port.");
}
{
// 多音字的生僻读音:大 读 tai 只占 大 全部权重的 0.14%,单字行要在 penalty 上扣分,
// 否则 tai'zhuan'qian'le 会被语言模型带成「大赚钱了」。太/台 只有这一个读音,不扣;
// 长 读 chang 占四成,是常见读音,也不扣。权重取自出厂词库。
sqlite3 *db = nullptr;
if (sqlite3_open(":memory:", &db) != SQLITE_OK)
throw std::runtime_error("Failed to open the in-memory dictionary.");
const char *fixture = "CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_d VALUES('da','d','大',3273479),('dai','d','大',4993);"
"INSERT INTO tbl_1_t VALUES('tai','t','太',768757),('tai','t','台',51622),"
"('tai','t','大',4719);"
"INSERT INTO tbl_1_c VALUES('chang','c','长',340269);"
"INSERT INTO tbl_1_z VALUES('zhang','z','长',462258);";
if (sqlite3_exec(db, fixture, nullptr, nullptr, nullptr) != SQLITE_OK)
throw std::runtime_error("Failed to build the polyphone fixture.");
const auto totals = quanpin::load_char_reading_totals(db);
if (totals->size() != 2 || totals->count("大") != 1 || totals->count("长") != 1)
throw std::runtime_error("Reading totals must keep exactly the polyphonic characters.");
std::unordered_map<std::string, sqlite3_stmt *> statements;
const auto penalty_of = [&](const std::string &syllable, const std::string &word) {
const auto lookup = quanpin::make_lattice_db_lookup(db, statements, 32, {}, totals);
for (const auto &row : lookup({syllable}))
{
if (row.value == word)
return row.penalty;
}
throw std::runtime_error("Polyphone fixture row missing: " + word);
};
const double rare = penalty_of("tai", "大");
if (rare < 1.5 || penalty_of("tai", "太") != 0.0 || penalty_of("tai", "台") != 0.0 ||
penalty_of("chang", "长") != 0.0 || penalty_of("da", "大") != 0.0)
throw std::runtime_error("Rare polyphone readings must be penalized, common ones must not: " +
std::to_string(rare));
// 不给 totals 时保持旧行为,一分不扣。
for (const auto &row : quanpin::make_lattice_db_lookup(db, statements, 32)({"tai"}))
{
if (row.penalty != 0.0)
throw std::runtime_error("Lookup without reading totals must not penalize.");
}
for (auto &[sql, statement] : statements)
sqlite3_finalize(statement);
sqlite3_close(db);
}
{
// Neural-only mode still asks the lattice for twelve internal alternatives, but it must not
// expose the lattice's own pick while the asynchronous neural result is pending.