用 RAG+FAISS 构建古诗词 AI 取名系统:从向量检索到六层质量过滤的实战
用 LLM 生成古诗词名字,最大的问题不是"好不好听",而是它会编造出处。
让大模型给你推荐一个出自《诗经》的名字,它大概率会自信满满地给你一个假的诗句、假的篇名、假的释义。这不是模型能力问题,而是幻觉的结构性根源------它没有引用真实语料的机制。
所以我做了一个系统:每个名字都必须来自真实可检索的古诗词原文,通过 RAG(检索增强生成)架构强制约束。以下是完整的技术拆解。
一、架构全景
先看整体数据流:
scss
用户输入 离线预处理
───────── ──────────
姓氏+性别+出生时间 38万句古诗词原文
│ │
▼ ▼
八字计算 → 五行分析 繁→简转换(OpenCC)
│ │ 句子切分(≥4字)
│ 喜用元素 负面意象过滤
│ │ │
│ 映射意象关键词 │
│ │ │
▼ ▼ ▼
拼接搜索query Embedding向量化
│ │ │
▼ │ ▼
Query向量化 │ FAISS索引(525MB)
│ │ │
└────┬─────┘ │
▼ │
Top-K相似检索 ◄───────────┘
│
▼
候选诗句池(50首)
│
▼
LLM Prompt生成(严格约束不许编造)
│
▼
流式输出 → SSE → 前端逐个渲染
│
▼
六层质量过滤
┌─────────────────────────────┐
│ 1. 烂大街字黑名单 │
│ 2. 谐音梗检测 │
│ 3. 生僻字过滤 │
│ 4. 音韵评分(pypinyin) │
│ 5. 五格数理(81数理吉凶) │
│ 6. 历史去重 │
└─────────────────────────────┘
│
▼
最终名字 + 诗词出处 + 完整解析
技术选型一览:
| 环节 | 选择 | 理由 |
|---|---|---|
| 向量化 | Zhipu Embedding-3 | 中文语义理解能力强,1024 维 |
| 向量检索 | FAISS(本地) | 无外部依赖,支持 mmap 加载 |
| LLM | DeepSeek-V4 | 中文能力强,OpenAI 兼容 API |
| 元数据 | SQLite + JSON 双备 | 轻量,WAL 模式支持并发读 |
| 流式协议 | SSE | 比 WebSocket 简单,单向推送够用 |
二、向量检索 + 域知识注入
2.1 基础检索
古诗词语料经过预处理后(繁→简转换、按句切分、负面过滤),通过 Embedding-3 向量化存入 FAISS 索引。查询时,将用户需求编码为自然语言:
python
query = f"姓氏:{surname},性别:{gender},风格:古典诗词,意境优美"
query_vec = embedding_client.embed(query)
faiss.normalize_L2(query_vec) # L2归一化 → 等价余弦相似度
_, indices = index.search(query_vec, k=50)
candidates = [metadata[i] for i in indices[0]]
用自然语言而非关键词拼接,是因为 Embedding-3 对语义的理解远比关键词匹配准确。"意境优美的古诗词"这样的描述,能检索到包含山水、清风、明月等意象的诗句,而关键词"优美"本身可能一句都匹配不上。
2.2 八字五行 → 向量偏移
这是整个系统最独特的设计。传统取名软件的五行补救,是推荐固定偏旁(缺水带"氵",缺木带"木")。但古诗词取名不能这样做------你不能限制用户只能用带"氵"的字。
我的方案是:把五行喜用转化为古诗意象关键词,注入 FAISS 检索 query,让向量检索自然偏移到含对应意象的诗句。
erlang
出生时间
│
▼
四柱八字(年月日时 → 天干地支)
│
▼
十神分析 → 日主强弱 → 旺衰法
│
▼
喜用神 → 意象关键词映射
│
▼ 五行 → 意象关键词
│ ──────────────────────
│ 水 → "雨 云 河 溪 澜 泉 润"
│ 木 → "林 松 竹 兰 芳 蕊"
│ 金 → "玉 银 锋 铭 锐 钰"
│ 火 → "日 炎 晖 曜 煜 烨"
│ 土 → "山 岳 峰 坤 垣 培"
▼
注入检索query → 偏移到含对应意象的古诗
核心代码:
python
WUXING_KEYWORDS = {
"金": ["玉", "银", "锋", "铭", "锐", "钰", "鑫"],
"木": ["林", "松", "竹", "兰", "芳", "蕊", "萱"],
"水": ["雨", "云", "河", "溪", "澜", "泉", "润"],
"火": ["日", "炎", "晖", "曜", "煜", "烨", "灿"],
"土": ["山", "岳", "峰", "坤", "垣", "培", "城"],
}
def build_query(surname, gender, favored_elements):
"""将五行喜用映射为古诗意象,注入检索query"""
base = f"姓氏:{surname},性别:{gender},风格:古典诗词"
imagery = []
for elem in favored_elements:
imagery.extend(WUXING_KEYWORDS[elem][:3])
if imagery:
return f"{base},意象:{' '.join(imagery)}"
return base
为什么不直接用关键词过滤("包含'水'字的诗句")?因为"水"的意象远不止字面包含"水"的诗句。"落霞与孤鹜齐飞,秋水共长天一色"里"水"不是主角,但整体意象是水。Embedding-3 能捕捉这种语义关联,关键词过滤做不到。
实测效果:缺水的八字,注入"雨 云 河 溪"后,检索到的候选诗句中含水意象的比例从 15% 提升到 60% 以上。向量检索确实会往对应意象偏移。
2.3 换一批时的多样性策略
用户点"换一批"时,不能简单地再跑一次同样的检索------大概率会得到相似的结果。
解决方案是方向词轮转:维护一组主题方向,每次换一批切换一个,让 FAISS 检索偏移到不同诗歌集群。
python
DIRECTION_WORDS = [
"山水田园", "日月星辰", "花鸟鱼虫",
"德行修养", "志向抱负", "清雅温婉"
]
def rotate_query(base_query, used_directions):
"""换一批时切换意象方向,保证每轮结果不同"""
available = [d for d in DIRECTION_WORDS if d not in used_directions]
direction = random.choice(available)
used_directions.append(direction)
return f"{base_query},主题方向:{direction}"
"山水田园"会偏移到王维、孟浩然的诗句;"日月星辰"则偏向李白、苏轼。每轮结果自然不同。
三、Prompt 工程与流式解析
3.1 约束 LLM 不编造出处
Prompt 设计中最关键的约束:
markdown
【硬约束】只能使用上述提供的诗句原文,绝不编造任何诗句、出处或释义。
如果候选诗句中没有合适的,宁可少生成一个名字,也不许杜撰。
【跨诗规则】名字的每个字必须来自不同诗句。
最多一个字来自同一首诗,以保证文化关联的丰富性。
【格式要求】使用 <<NAME>> 分隔符逐个输出每个名字,
每个名字包含:名字、出处、诗句原文、释义、思考过程
跨诗规则是容易忽略但很重要的设计------如果两个字来自同一首诗,名字的文化厚度就局限在一首诗里。跨诗取字让每个名字有多重文化关联,解释空间更大。
但即使有严格的 prompt 约束,LLM 仍然可能编造。所以还需要后处理校验:把 LLM 引用的诗句在原文库里做精确匹配,不匹配的直接丢弃,触发重新生成。两道防线确保零编造。
3.2 <> 流式解析
LLM 流式输出最大的工程挑战是:输出中途不是合法的 JSON。
假设 LLM 要输出 {"names": [{"name": "清涵"}, ...]},在流式传输过程中,你收到的前几个 chunk 可能是 {"names": [{"name": "清------这不是合法 JSON,无法解析。
常见方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 等完整输出再解析 | 实现简单 | 失去流式意义,延迟高 |
| JSON 流式解析器 | 能增量解析 | 复杂、脆弱、LLM 输出风格微变就挂 |
| 自定义分隔符 | 简单、通用、零依赖 | 需要在 prompt 里约定格式 |
我选了自定义分隔符方案。Prompt 要求 LLM 用 <<NAME>> 作为名字之间的分隔标记,解析端只需按这个标记切分缓冲区:
python
async def stream_names(llm_stream):
"""从LLM流中增量解析每个名字块"""
buffer = ""
async for chunk in llm_stream:
buffer += chunk
# 每发现一个<<NAME>>标记,就切出一个完整的名字块
while "<<NAME>>" in buffer:
block, buffer = buffer.split("<<NAME>>", 1)
if block.strip():
yield parse_name_block(block)
# 最后一个块没有后续<<NAME>>标记,单独处理
if buffer.strip():
yield parse_name_block(buffer)
def parse_name_block(text):
"""从名字块中提取结构化数据"""
result = {}
for line in text.strip().split("\n"):
if ":" in line:
key, value = line.split(":", 1)
result[key.strip()] = value.strip()
return result
前端通过 SSE(Server-Sent Events)接收,每收到一个完整的名字块就立即渲染,用户看到的效果是名字一个接一个地"跳出来"------比等 10 秒后一次性展示 5 个名字的体验好得多。
SSE 在 Nginx 后面有个经典坑:Nginx 默认会 buffer 响应,导致前端收不到实时推送。需要两处配置:
nginx
# nginx.conf
location /api/ {
proxy_buffering off; # 关闭proxy层buffering
proxy_set_header X-Accel-Buffering no; # 关闭X-Accel层buffering
proxy_pass http://backend;
}
同时后端响应头也要加 X-Accel-Buffering: no,双保险。
四、六层质量过滤
LLM 生成的名字不能直接展示,需要经过多层质量把关。每一层解决一个具体问题:
第 1 层:烂大街字黑名单
python
BANNED_CHARS = set("梓涵轩诺萱妍泽宇欣怡浩瑞昊辰")
为什么要过滤这些字?2023 年新生儿重名统计,"梓涵"连续多年霸榜。用了这些字,再好的诗词出处也白搭------别人一看就知道是 AI 起的"模板名"。宁可少生成,也不能出烂大街的字。
第 2 层:谐音梗检测
姓+名组合的谐音问题,比如"杨伟"、"范统"、"杜子腾"。维护一个谐音黑名单,每个新名字都做组合检测。这类问题一旦漏掉,用户体验直接归零。
第 3 层:生僻字过滤
拒绝 CJK 扩展区(0x9FFF 以上)的字。名字是用来叫的,别人叫不出来、打不出来,再有文化也白搭。
python
def has_rare_char(name):
return any(ord(c) > 0x9FFF for c in name)
第 4 层:音韵评分
用 pypinyin 拆解声母韵母和声调,对名字做可读性评分。主要检测:
- 同声母连读:如"赵兆",zh-zh 连读拗口
- 同韵母连读:如"林音",in-in 听起来像一个字
- 三声连读:两个三声字连读需要变调,处理不好会奇怪
- 整体韵律:声调起伏太平或太陡都不好
评分低于 60 分的直接过滤。
第 5 层:五格数理
这是最"不技术"的一层,但对产品转化至关重要。
五格数理(天格、人格、地格、外格、总格)是中国传统取名的参考体系,用姓名笔画数对应 81 数理的吉凶。开发者可能觉得这是迷信,但准爸妈们在乎------他们会拿着你给的名字去"起名网"验证五格,如果人格是"大凶",他们不会用你的产品。
所以系统用康熙字典笔画数自动计算五格,人格落"大凶"的组合直接排除。这层不是给开发者看的,是给用户信心的。
python
def wuge_is_bad(surname, name):
"""检查五格数理是否为大凶"""
strokes = get_kangxi_strokes(surname + name)
ren_grid = strokes[0] + strokes[1] # 人格 = 姓末字+名首字笔画
return ren_grid in BAD_NUMBERS # 81数理中的大凶数
第 6 层:历史去重
用户点"换一批"时,新生成的名字不能和之前展示过的重复。session 中维护已展示名字集合,每层过滤后做最终去重。
六层全通过,才算一个合格的名字。实测下来,LLM 每轮生成 5 个名字,平均有 1-2 个会被过滤掉,所以系统会多生成几个作为缓冲。
五、踩过的坑
坑 1:让 LLM "不编造"有多难
最初 prompt 只写了"请使用提供的诗句",编造率约 30%。加了"绝不编造任何诗句或出处"后降到 10%。加上后处理的精确匹配校验,才做到零编造。
经验:prompt 约束是第一道防线,后处理校验是兜底。两道都不能少。只靠 prompt 就像只靠自律------大部分时候有用,但总会翻车。
坑 2:FAISS 索引的冷启动
525MB 的索引文件,首次加载要几秒。改用 mmap 后 RSS 占用从几个 GB 降到几十 MB,但第一次查询仍然有冷启动延迟(需要把向量数据从磁盘读入页缓存)。
解决方案:服务启动时做一次空查询"预热",把常用页拉入缓存。
python
# 服务启动后预热FAISS索引
warmup_vec = np.zeros((1, dim), dtype=np.float32)
index.search(warmup_vec, k=1) # 触发页缓存加载
坑 3:Embedding 查询缓存的必要性
相同输入(同姓氏+同性别+同风格)的查询会反复出现。为 query embedding 加了 LRU 缓存(maxsize=128),命中率出乎意料地高------同一个 session 内的"换一批"操作,只有方向词在变,基础 query 的 embedding 可以复用。
六、实际效果
以上技术方案已经落地为一个完整产品------文间拾字。展示几张实际运行效果:
输入界面:用户填写姓氏、性别、出生时间,选择风格偏好:

生成过程:通过 SSE 流式推送,名字逐个"跳出来",加载期间展示古诗词轮播:

结果展示:每个名字附带完整信息------诗词出处(朝代+作者+原句)、五行标注、音韵分析、取名思路:

每个名字卡片的信息密度很高:诗词原文保证有据可查,五行标注让用户知道"补了什么",音韵分析让用户知道"念起来好不好听",取名思路让用户理解"为什么选这两个字"。
七、写在最后
这个系统的核心思路是:用 RAG 架构约束 LLM 的幻觉,用向量检索保证诗词出处的真实性,用域知识(八字五行)增强检索的相关性,用多层过滤保证名字的质量。
技术栈并不复杂------FAISS、Embedding、LLM、SQLite,都是成熟的工具。真正的难点在于把"古诗词取名"这个垂直领域的业务逻辑(五行、音韵、五格)和技术架构(向量检索、流式输出、质量过滤)有机地融合在一起。
如果你也在做 RAG 相关的项目,欢迎在评论区交流。特别是"如何约束 LLM 不编造引用源"这个问题,很想听听大家的方案。
体验一下
上面拆解的所有技术,都已经跑在这个产品里了:
微信搜索小程序「文间拾字」 即可体验。
收录《诗经》《楚辞》《全唐诗》《宋词》等经典,所有名字 100% 有诗词原文出处,零编造。
如果你身边有正在给宝宝起名的朋友,可以把这个工具分享给他们------比起花钱找大师,不如让 AI 翻遍 270 万句古诗词,给名字找一个真正有据可查的出处。
评论区互动
评论区留下「姓氏 + 性别」(如"张 + 男孩"),我用这个系统帮你跑一版,看看效果如何 👇