Compromise 实战配方指南:从可变性陷阱到 PII 脱敏的 17 个开箱即用场景
Compromise 实战配方指南:从可变性陷阱到 PII 脱敏的 17 个开箱即用场景
本指南以 docs/recipes.md 为骨架,系统梳理 Compromise(modest natural-language processing)在日常开发中最常用的 17 个实战配方:从必须牢记的「可变性规则」、命名实体提取、动词时态改写、查找替换、PII 脱敏、名词单复数、数字处理,到匹配语法、句子过滤、否定、缩略词展开、文本归一化、结构化 JSON 输出、自定义词表、插件编写与 match 调试。每个片段都可在当前仓库中真实运行,并配有源码级佐证;读完你将能够直接复制这些代码解决实际 NLP 任务,并理解其背后的可变性(mutability)与选择(selection)机制。
所有示例默认你已经完成导入:
import nlp from 'compromise'
建议先阅读 concepts.md 了解核心概念,尤其是下文第一条「可变性规则」——它是大多数踩坑的根源。
可变性规则(务必先读)
Compromise 的变换方法(如 toPastTense()、replace()、toPlural())会就地修改底层文档(in place),而方法返回的 View 只是「被选中的子集」,不是整篇文档。因此正确做法是:从最初的 doc 上读取结果:
let doc = nlp('I walk to work')
doc.verbs().toPastTense() // 就地修改 doc;返回值只是 "walked" 这个选择
doc.text() // 'I walked to work' ✅ 从 doc 读取
// ⚠️ 常见错误 —— 这里的 .text() 只取到了被选中的片段:
nlp('I walk to work').verbs().toPastTense().text() // 'walked work' (不是整个句子)
想在不污染原文档的前提下变换一份副本,使用 .clone():
let doc = nlp('I walk')
let past = doc.clone().verbs().toPastTense().text() // 'walked'
doc.text() // 'I walk' (原封不动)
这一设计贯穿全部变换类 API。从源码结构看,各子选择器(如 nouns/api/api.js 中的 Nouns、numbers/numbers/api.js 中的 Numbers)都是 View 的子类,通过 map/replaceWith 等操作直接在共享的 document 上改写,再返回一个持有新指针的视图——这正是「返回的是选择、改动的是全文」的根源。
提取命名实体(人物、地点、组织)
let doc = nlp('Mary met Dr. John Smith in Paris.')
doc.people().out('array') // ['Mary', 'Dr. John Smith']
doc.places().out('array') // ['Paris.']
nlp('Google and Mary went to Paris.').topics().out('array') // ['Google', 'Paris.', 'Mary']
.topics() = 人物 + 地点 + 组织 的并集。实体识别依赖 data/lexicon 中的姓名、城市、国家等词表(如 people.js、places.js、organizations.js)以及 topics 插件的组合逻辑。
变换动词时态
let doc = nlp('I walk to work')
doc.verbs().toPastTense(); doc.text() // 'I walked to work'
doc.verbs().toFutureTense(); doc.text() // 'I will walk to work'
时态方法同样存在于 .sentences() 上,可对整个句子进行改写。实现位于 verbs/api/api.js,其中 toPast、toPresent、toFuture 分别是三个时态方法的别名;否定变换由 conjugate/toNegative.js 完成。此外还有 toGerund()、toInfinitive()、toPastParticiple() 等动词形态方法,可配合 verbs 测试 查看各类动词的变换预期。
查找与替换
let doc = nlp('I love cats')
doc.replace('cats', 'dogs') // 按模式进行搜索-替换
doc.text() // 'I love dogs'
// 替换某个选择匹配到的内容:
let d2 = nlp('the cat sat')
d2.match('#Noun').replaceWith('dog')
d2.text() // 'the dog sat'
replace() 接受与 match() 相同的模式语法(见下文的「匹配一个模式并取出片段」),replaceWith() 则直接改写当前选择。相关实现可参考 change/api 与 match 插件;replace-sub 等边界行为见 tests/one/change/replace-sub.test.js。
脱敏 / 匿名化 PII
let doc = nlp('Mary called John')
doc.people().replaceWith('███')
doc.text() // '███ called ███'
内置的 .redact() 可一次性移除人物、地点、组织、邮箱、电话号码等敏感信息:
nlp('Mary joined Google today. Call (800) 555-0000 or email alice@example.com.')
.redact().text()
// '██████████ joined ██████████ today. Call ██████████ or email ██████████.'
实现位于 redact/redact.js(通过 redact/plugin.js 挂载为 View.prototype.redact)。两点说明:
- 默认开启的类别包括 people、places、organizations、acronyms、money、percentages、fractions、emails、phoneNumbers、atMentions、urls;而
properNouns、dates、numbers、pronouns默认关闭(置为false即跳过)。可通过选项关闭某一类,例如doc.redact({ people: false })。 - 注意:当前仓库中
organizations默认是开启的(redact-organizations测试里Mary joined Google today会把 Google 一并替换),这与 recipes.md 中"默认不脱敏组织"的旧描述不同——如果你使用的是当前版本,组织名会被默认脱敏;文档的历史说明可作参考。脱敏时文本被替换为██████████(可通过第二参数自定义),并打上Redacted标签且保留原词性标签(keep=true),见 tests/three/redact.test.js。
名词复数化 / 单数化
let doc = nlp('one good dog')
doc.nouns().toPlural(); doc.text() // 'one good dogs'
let d2 = nlp('three turnovers')
d2.nouns().toSingular(); d2.text() // 'three turnover'
nouns() 返回 Nouns 视图(见 nouns/api/api.js),除了 toPlural/toSingular 还提供 isPlural()、isSingular()、adjectives()、parse()、conjugate()(给出 Singular/Plural 词形)等方法。复数规则引擎在 pairs(如 Plural.js),匹配测试见 tests/three/nouns/toPlural.test.js。
处理数字
nlp('it cost twelve dollars').numbers().get() // [12]
nlp('five hundred').numbers().toNumber().text() // '500' (文字 → 数字)
nlp('it is 5 km').numbers().toText().text() // 'five' (数字 → 文字)
数字视图还支持 .greaterThan()、.lessThan()、.between()、.add()、.subtract()(别名 .minus()、.plus()、isBetween),以及 .set()、.isEqual()(别名 .equals())、.increment()、.decrement()、.isOrdinal()、.isCardinal()、.toLocaleString()(别名 .toNice())等,全部定义在 numbers/numbers/api.js。货币、分数、百分比也有各自的子选择:.money()、.fractions()、.percentages()(后两者与数字共用 Numbers 类),View.prototype.values 是 numbers 的别名。
匹配一个模式并取出片段
let doc = nlp('the price of milk is high')
doc.match('price of [<thing>.]').groups('thing').text() // 'milk'
方括号 <a href="https://link.gitcode.com/i/b1bf0ba8ac75550d0ff7123be9909c57" target="_blank"> ... ] 圈定捕获片段,<name> 为其命名,. 表示任意一个词,然后用 .groups('thing') 取回。完整的模式语言(词性标签 #Tag、正则、*/+ 量词、or/not、前后查找等)见 [match-syntax.md,实现位于 1-one/match,详尽的模式测试在 tests/two/match 与 tests/one/match 下。
判断文本是否包含某内容(聊天机器人式路由)
nlp('the deal is closed').has('#Determiner #Noun') // true
nlp('I love cats').has('love #Plural') // true
.has() 返回布尔值且永不修改文档,非常适合做意图路由/关键词命中判断。其内部基于匹配引擎实现(见 1-one/match 的 api),因此支持完整的 match 语法。
过滤句子
nlp('I am here. Are you ok?').questions().out('array') // ['Are you ok?']
nlp('I like cats. Dogs are loud.').sentences().if('#Plural').out('array')
// ['I like cats.', 'Dogs are loud.'] —— 只保留含复数名词的句子
.questions() 通过 sentences/questions.js 识别问句(sentences().isQuestion() 是同义方法);.sentences().if(pattern) 按模式过滤句子。完整的句子 API(isExclamation()、toPastTense()、toNegative() 等)见 sentences/api.js。
否定一个句子
let doc = nlp('he is happy')
doc.sentences().toNegative()
doc.text() // 'he is not happy'
句子级否定由 verbs/api/conjugate/toNegative.js 驱动,会依据动词形态插入否定词;相关预期见 tests/three/verbs/toNegative.test.js。
展开缩略词
let doc = nlp("she isn't here")
doc.contractions().expand()
doc.text() // 'she is not here'
缩略词处理拆分为两层:一层的收缩/展开在 1-one/contraction-one(compute 与 model),更复杂的规则在 2-two/contraction-two。测试见 tests/two/contractions/expand.test.js 与 contract.test.js。
归一化混乱文本
nlp('I LOVE Café!!').normalize().text() // 'I LOVE Cafe!'
.normalize({ ... }) 支持选项控制空白、大小写、unicode 变体、标点、缩略词等处理维度,归一化逻辑集中在 3-three/normalize(methods.js 定义各规则,api.js 暴露入口),预置与自定义选项的测试见 tests/three/normalize。
导出结构化数据
nlp('big cats').json()[0].terms.map(t => t.text) // ['big', 'cats']
nlp('hi there').json({ offset: true })[0].offset // { index: 0, start: 0, length: 8 }
.json() 接受若干开关,返回带 terms 等字段的数组;offset: true 会附带每个词在原文中的位置信息(index/start/length),便于做高亮或审计。其他可用标志包括 tags、normal、reduced 等,完整说明见 api.md;输出实现位于 1-one/output。
教它认识新词
解析时传入 lexicon 对象(仅对本次调用生效):
nlp('kermit waved', { kermit: 'FirstName' }).people().out('array') // ['kermit']
全局注册,使用 nlp.addWords()(值必须是有效标签,标签列表见 tags.md):
nlp.addWords({ frodo: 'FirstName', gandalf: 'FirstName' })
nlp('frodo met gandalf').people().out('array') // ['frodo', 'gandalf']
词表机制由 1-one/lexicon 插件实现(methods 提供查找逻辑),仓库内置词表在 data/lexicon/index.js。除了 addWords,还可用 doc.tag() 直接给已解析的词打标签(见 tests/one/lexicon/lexicon.test.js)。
编写插件(添加自己的方法)
nlp.plugin({
// 向词表添加单词
words: { kermit: 'FirstName' },
// 向标签图添加新标签
tags: { Muppet: { isA: 'Person' } },
// 添加可链式调用的新方法
api: (View) => {
View.prototype.exclaim = function () {
return this.post('!') // 在每个匹配后追加 '!'
}
},
})
let doc = nlp('hello there')
doc.match('there').exclaim()
doc.text() // 'hello there!'
插件形状(words/tags/api/model/compute 等)的完整说明见 concepts.md,View.prototype 的扩展方式见 extend.js。仓库 plugins 目录下有大量真实插件可供参考:日期 plugins/dates、统计 plugins/stats、维基百科 plugins/wikipedia、语音 plugins/speech 等——写插件前先看看它们如何组织 plugin.js 与 api.js。
当 .match() 匹配不到东西时
按以下顺序排查:
- 确认标签真实存在——见 tags.md。
#Name、#Location、#Adj都不是标签;人物应是#Person,地点是#Place,形容词是#Adjective(完整标签集也可在 src/2-two/preTagger/tagSet 中查看)。 - 匹配不会跨句子边界——模式只能在一个句子内部匹配,需要跨句请先合并或对单句逐一匹配。
- 用
doc.debug()打印每个词实际被标注的标签——可直观看到词性标注结果,快速定位是分词还是打标环节出了问题。 - 精确词只能匹配字面单词——想匹配所有屈折形态(如 run/runs/ran/running),使用
{root}语法(见 match-syntax.md)。
更多匹配陷阱与负向断言、贪婪捕获等高级用法,可结合 tests/two/match 下的 not.test.js、greedy-capture.test.js 等用例对照学习。
附:本文配方与源码对应关系速查
这些配方覆盖了日常文本处理的高频需求:聊天机器人意图判断、内容清洗与归一化、日志/用户内容的 PII 脱敏、结构化信息抽取、文本风格改写(时态、否定、单复数)。遇到新需求时,先想清楚「我要读结果还是改文档」,再用 match() 定位、用对应子视图变换,最后用 json()/out('array') 取出结构化结果即可。