项目概述
寻忆 (Xunyi-Anime) 是一个本地化部署的多模态 RAG 动漫推荐系统。用户输入纯文本描述或上传动漫截图,系统通过三级检索架构从知识库中召回最匹配的番剧,并由大模型生成详细的推荐理由。评测数据显示,其 Top-3 召回准确率达到 94%。
核心技术决策:全链路本地化(数据爬取→索引构建→推理均可在单机完成),混合检索 + 精排的三级流水线,以及多模态视觉语言模型对图片输入的支持。
系统架构总览
整个流水线分为六个阶段:数据采集 → 数据清洗 → 索引构建 → 混合检索 → 精排重排 → 大模型生成。每一阶段都有明确的技术选型依据。
目录结构
xunyi-anime/
├── src/
│ ├── crawler/spider.py # 动漫数据爬虫
│ ├── data_prep/cleaner.py # 数据清洗与文档化
│ ├── retrieval/
│ │ ├── vector_store.py # 稠密向量索引 (BGE-M3 + FAISS)
│ │ ├── bm25_retriever.py # 稀疏检索索引 (BM25 + jieba)
│ │ ├── ensemble.py # 混合检索与 RRF 融合
│ │ └── reranker.py # BGE-Reranker 精排
│ ├── llm/qwen_model.py # Qwen3-VL 多模态推理封装
│ ├── evaluation/ # Top-3 准确率评估
│ ├── rag_chain.py # RAG 主流水线
│ └── app.py # Gradio Web 界面
├── data/ # 原始与处理后数据
├── indices/ # FAISS / BM25 索引
└── evaluation/results/ # 评估结果
一、数据采集:Playwright + BeautifulSoup 双重协作
技术选型
| 技术 | 用途 | 选型原因 |
|---|---|---|
| Playwright | 动态浏览器渲染 | bgm.tv 排行榜页面依赖 JavaScript 动态加载,requests/urllib 无法获取完整 DOM |
| BeautifulSoup4 | HTML 解析与提取 | 轻量级解析器,CSS 选择器语法直观,适合结构化数据的快速提取 |
| asyncio | 异步爬虫调度 | Playwright 原生支持 async API,异步请求可大幅提升爬取吞吐 |
实现手段
爬虫目标为 bgm.tv 的动漫排行榜,核心流程如下:
- 分页遍历排行榜:按
sort=rank&page={i}依次请求排名前 420 页(约 10000 部动漫) - 列表页解析:用
#browserItemList liCSS 选择器提取每个条目,获取中文名、日文原名、评分、封面 URL 及详情页链接 - 详情页抓取:为每部动漫访问独立的 subject 页面,提取
#subject_summary(剧情简介)和.subject_tag_section(标签体系) - 礼貌爬取:每次请求间隔 0.5 秒,页面间间隔 1 秒,避免对 bgm.tv 造成压力
- Headless 模式:Chromium 无头启动,不弹出浏览器窗口
# 核心协程结构
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
for i in range(1, 421):
html = await fetch_anime_page(page, f"{base_url}{i}")
page_data = parse_anime_list(html)
for anime in page_data:
detail_html = await fetch_anime_detail(page, anime["subject_url"])
anime.update(parse_anime_detail(detail_html))
数据格式
最终输出为 JSON 数组,每部动漫包含 name, name_jp, info, score, cover_url, subject_url, summary, tags 八个字段。
二、数据清洗:结构化文档化
技术选型
清洗层无需外部 NLP 库 —— 数据已高度结构化,只需要字段拆分与拼接。核心逻辑在 cleaner.py 中实现。
实现手段
- info 字段拆解:原始
info字段格式如"12话/2022年4月/京都动画/漫画改/池田和美",以/分隔符拆分为episodes,date,staff,source,character_design五个独立字段 - 标签标准化:将标签列表
["热血", "篮球", "运动"]拼接为"热血、篮球、运动" - page_content 构造:将各字段拼接为一段结构化中文描述,用于后续的稠密和稀疏检索编码
- metadata 保留:元数据(含封面 URL、原始评分、链接)完整保留,供 LLM 生成推荐卡片时引用
文档化后的统一格式:
{
"page_content": "动漫名称:黑子的篮球 话数:25 标签:热血、篮球、运动 简介:...",
"metadata": {
"name": "黑子的篮球",
"name_jp": "黒子のバスケ",
"date": "2012年4月",
"episodes": "25",
"score": "7.5",
"cover_url": "https://...",
"tags": ["热血", "篮球", "运动"],
"summary": "..."
}
}
这样设计的好处是:page_content 用于检索编码,metadata 用于生成阶段的封面展示与结构化引用。
三、检索层:混合检索(Dense + Sparse)三级架构
这是系统最核心的技术设计,也是 94% 召回率的关键支撑。
3.1 稠密检索:BGE-M3 + FAISS
为何选 BGE-M3:BAAI 发布的 BGE-M3 是当前最强的多语言 Embedding 模型之一,支持超过 100 种语言,并在中、英、日等多语种混合场景下表现突出。动漫数据天然包含中文名称、日文原名、中英混合标签,BGE-M3 的多语言对齐能力恰好契合这一需求。
为何选 FAISS:Facebook Research 开发的 FAISS 是工业界最成熟的向量检索引擎之一,支持 GPU 加速、局部敏感哈希(LSH)和乘积量化。对于本地部署场景,FAISS 无需额外的数据库服务,索引文件可直接序列化到磁盘。
# 稠密向量编码
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={'device': 'cuda'},
encode_kwargs={'normalize_embeddings': True}
)
# FAISS 索引构建
vector_store = FAISS.from_texts(texts, embeddings, metadatas=metadatas)
vector_store.save_local("indices/faiss_index")
归一化嵌入:设置 normalize_embeddings=True,使向量内积等价于余弦相似度,避免了 L2 距离与余弦相似度之间的语义偏差。
3.2 稀疏检索:BM25 + jieba 分词
为何需要稀疏检索:稠密向量擅长语义相似匹配(如「打篮球」→「运动番」),但会漏掉精确关键词匹配(如番剧名为「Blue Period」搜索「蓝色时期」)。BM25 基于词频-逆文档频率(TF-IDF),提供精确的词汇级匹配,与稠密检索形成互补。
为何选 jieba:动漫描述以中文为主,jieba 是中文自然语言处理中最成熟的分词工具之一。相比于基于字符 n-gram 的 tokenization,语义级分词(如「热血」→ 一个 token)更能保留领域术语。
tokenized_corpus = [list(jieba.cut(doc['page_content'])) for doc in documents]
bm25 = BM25Okapi(tokenized_corpus)
BM25 公式:
对于给定查询 $q$ 和文档 $d$:
$$\text{BM25}(q,d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}$$其中 $k_1$ 控制词频饱和度,$b$ 控制文档长度归一化。默认参数 $k_1=1.5, b=0.75$ 在大多数场景下表现良好。
3.3 混合融合:Reciprocal Rank Fusion (RRF)
为何使用 RRF:Dense 和 Sparse 检索返回的相似度分数量纲不同,简单线性加权会偏向某一侧。RRF 是基于排名的融合算法,不依赖原始分数绝对值,只考虑文档在两个列表中的相对位置,天然适合异构检索器的结果融合。
# RRF 融合核心实现
fused = {}
for weight, docs in ((0.5, vector_docs), (0.5, bm25_docs)):
for rank, doc in enumerate(docs, start=1):
key = doc.metadata.get("name")
score = weight / (rank + 60) # RRF 公式
fused[key][1] += score
RRF 公式:
$$\text{RRFscore}(d) = \sum_{r \in R} \frac{1}{k + r(d)}$$其中 $R$ 是所有检索器的集合,$r(d)$ 是文档在某个检索器结果列表中的排名,$k=60$ 是一个经验性的平滑常数,用于削弱排名极高文档的权重优势。
Dense 和 Sparse 等权($w_d = w_s = 0.5$)融合,各取 Top-20 送入下一阶段。
3.4 重排序:BGE-Reranker
为何需要重排:第一阶段的 RRF 融合仅依据排名,未精确计算查询与文档的语义相关性。跨编码器(Cross-Encoder)将查询-文档对同时输入模型,注意力机制可以捕获二者之间的细粒度交互信息,精度远超双编码器(Bi-Encoder)。
pairs = [[query, doc.page_content] for doc in documents]
scores = self.reranker.compute_score(pairs)
doc_scores = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
return doc_scores[:top_k]
跨编码器 vs 双编码器对比:
| 维度 | Bi-Encoder (BGE-M3) | Cross-Encoder (BGE-Reranker) |
|---|---|---|
| 编码方式 | query 和 doc 独立编码 | query 和 doc 联合编码 |
| 交互机制 | 余弦相似度 | 全注意力交互 |
| 推理速度 | 快(文档可预编码) | 慢(每对需重新前向) |
| 精度 | 召回级 | 精确排序级 |
这正是三级架构的设计原理:Bi-Encoder 做粗筛(20→Top-3),Cross-Encoder 做精排,在精度与效率之间取得最优平衡。
四、大模型层:Qwen3-VL-8B-Instruct 多模态推理
技术选型
为何选 Qwen3-VL:Qwen3-VL 是阿里通义千问的第三代视觉语言大模型,原生支持图文混合输入。其 8B-Instruct 版本在消费级 GPU 上即可推理,32B 版本的性能不输 GPT-4V。对于本地部署的动漫推荐场景,8B 模型在 BF16 精度下仅需约 16GB 显存,单张 5090 可轻松承载。
为何选 BF16:BF16 保留与 FP32 相同的指数位宽(8bit),仅压缩尾数精度,在推理中几乎无精度损失,但显存占用减半。比 FP16 更稳定,不易产生数值溢出。
图片处理流程
图片不直接参与检索 —— 这避免了 CLIP 等模型的多模态 Embedding 与 BGE-M3 文本 Embedding 对齐问题。转而采用 「图片描述 → 文本检索」 的桥接策略:
- Qwen3-VL 接收图片,生成中文画面描述
- 将描述拼接到文本检索查询中
- 后续检索链路完全复用文本流水线
def describe_images(self, image_paths):
prompt = "请用中文概括图片中的动漫画面、角色外观、风格、氛围和可能的题材,用于检索相似番剧。"
return self.llm.generate(prompt, images=image_paths, max_new_tokens=160)
图像注入格式:
def _image_content(self, image_path):
abspath = os.path.abspath(image_path)
return {"type": "image", "image": f"file://{abspath}"}
Qwen3-VL 处理器支持 file:// 协议的本地路径,由 qwen_vl_utils.process_vision_info 统一处理图像预处理(缩放、分块)。
推荐生成
最终 prompt 将 Top-3 文档的元数据、剧情简介与图片描述组合,指示模型输出带理由的结构化推荐:
context = "\n\n".join([
f"番名:{doc.metadata['name']}\n资料:{doc.page_content}"
f"\n封面:{doc.metadata.get('cover_url', '')}"
for doc, _ in reranked_docs
])
prompt = f"""你是一个动漫推荐专家。请根据以下提供的动漫参考资料,回答用户的查询并给出推荐理由。
参考资料:
{context}
用户查询:{query}
{image_block}
要求:
1. 给出 Top-3 推荐。
2. 每个推荐都要结合参考资料给出详细的推荐理由。
3. 如果上传了图片,要结合图片分析解释相似点。
"""
工具链依赖
项目使用 HuggingFace transformers 加载模型,accelerate 实现自动设备映射,qwen-vl-utils 处理视觉输入。全程无需 vLLM 等高级推理框架,代码保持简洁可控。
五、用户界面:Gradio
技术选型
为何选 Gradio 而非 React/Vue:该系统定位为本地化部署工具,面向少量并发用户(个人或小团队)。Gradio 提供零前端代码的 ML Web 界面,三明治布局(左侧输入、右侧输出)适合推荐场景。若未来需要多用户并发,可替换为 FastAPI + 前端框架。
界面能力
- 文本输入框:支持自然语言查询,如「想看打篮球的热血番」
- 图片上传:支持动漫截图上传,系统自动将图片转为文本描述再检索
- 结果展示:大模型直接返回 Markdown 格式的推荐卡片
- 懒加载:RAG Chain 在首次请求时才初始化,避免启动时的模型下载等待
六、评估体系:定量验证
系统包含独立的 evaluate_top3.py 评估脚本,从知识库中随机采样 100 部动漫,基于元数据自动生成查询,验证目标动漫是否出现在 Top-3 推荐中。
评估方法
- 查询构造:对每部采样动漫,从其标签和简介中自动生成自然语言查询(如「我想找一部热血、篮球、运动相关的动画,剧情大概是:…」)
- 去污染:构造查询时移除番名与日文名,防止标签泄漏
- 命中判定:目标动漫名称是否出现在 Top-3 推荐列表中
- 多轮验证:支持自定义样本量与随机种子以保证统计显著性
评测结果
| 指标 | 数值 |
|---|---|
| 样本量 | 100 |
| 命中次数 | 94 |
| Top-3 准确率 | 0.94 |
| 耗时 | 32.9s (GPU) |
94% 的 Top-3 准确率验证了混合检索 + 重排序架构的有效性。
七、性能优化与部署
多 GPU 分载策略
模型加载支持独立设备配置:
XUNYI_DEVICE=cuda: LLM 推理设备(Qwen3-VL)XUNYI_EMBEDDING_DEVICE=cuda: Embedding 模型设备(BGE-M3)
在多 GPU 服务器上可以将大模型和 Embedding 模型分配到不同 GPU,避免显存竞争。
索引持久化
FAISS 索引以 save_local 全量序列化到 indices/faiss_index/,BM25 索引连同原始文档封装为 pickle 文件。首次启动后直接 load_local 加载,无需重新编码。
懒加载
核心模型均采用 _load_model() 懒加载模式:不在 __init__ 中加载,而在首次 generate()/rerank() 调用时才下载模型。这避免了索引构建阶段不必要的模型加载。
八、技术决策总结
| 层级 | 技术 | 核心取舍 |
|---|---|---|
| 爬虫 | Playwright + BS4 | 动态渲染 > 轻量请求(需要 JS 执行环境) |
| 稠密检索 | BGE-M3 + FAISS | 多语言语义匹配 > 单一语言模型 |
| 稀疏检索 | BM25 + jieba | 精确关键词互补 > 纯语义检索 |
| 融合策略 | RRF | 排名级融合 > 分数加权(异构检索器分数不可比) |
| 重排序 | BGE-Reranker | Cross-Encoder 精排 > Bi-Encoder 效率 |
| 大模型 | Qwen3-VL-8B | 本地部署可控性 > 云端 API 便捷性 |
| 图片理解 | 图片→文本→检索 | 桥接策略 > 多模态 Embedding 对齐 |
| 精度 | BF16 | 几乎无损的显存优化 > FP16 稳定性 |
| 界面 | Gradio | 快速原型 > 定制化 UI |
这套技术栈的核心思想是 「用最好的开源模型,做最可控的本地部署」。每一层都有清晰的职责边界和独立的可替换性 —— BGE 可替换为 Cohere,BM25 可替换为 ElasticSearch,Qwen3-VL 可替换为 GPT-4o,系统架构不受单一模型绑定。
结语
寻忆项目展示了如何以较少工程代价,构建一套精度可靠的本地化 RAG 系统。从数据采集到评估闭环,所有的技术选型都围绕「开源」、「本地化」、「多模态」三个关键词展开。对于希望搭建垂直领域 RAG 系统的开发者而言,其三级检索架构和评估方法论具有直接的参考价值。