尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从课设到RAG:唐诗检索系统中的信息检索链路全拆解

从课设到RAG:唐诗检索系统中的信息检索链路全拆解 简介人民大学信息检索导论期末大作业的唐诗检索系统属于高完成度课程设计源码面向计算机专业正在完成信息检索、自然语言处理方向大作业或课程设计的学生也适合需要项目实战练习的入门开发者。资源内含完整可运行的 Python 实现能够完成唐诗数据的获取、检索模型构建、查询与模型评估等流程并附带 README 文档说明与数据集文件。压缩包共 7 个文件以 .py 源码为主配合 .md 说明和 .csv 唐诗数据整体仅 3.69MB结构精简、易于上手。该项目为个人高分设计评审 99 分经过导师指导并认可通过代码完整、确保可运行既能用于期末大作业交付也可作为毕业设计或课程设计的参考范本。已有 78 人浏览学习适合希望借鉴完整检索系统设计思路、快速理解并复现项目的同学。1. 为什么唐诗检索不能只靠关键字匹配一个课设拆出来的信息检索链路要查一句“床前明月光”普通关键字就能命中。但一旦把问法换成“思乡的诗”或“李白写过哪些关于月亮的作品”关键词匹配与期望命中之间的断层立刻暴露。信息检索导论期末大作业里的唐诗检索系统就是围绕这个断层设计的先由 get_data.py 治理 tang_poems.csv再由 build_model.py 完成检索召回最后用 llm_query.py 和 client.py 把召回结果组装成回答。这套源码加文档说明在一门导论课里能拿到 99 分靠的不是哪一步有多黑科技而是数据、索引、生成三段咬得紧。适合正在做课程设计的学生也适合想快速搭一套 RAG 演示的人。这篇文章按数据、模型、生成、验证四个环节把这段链路拆开。2. 语料治理决定索引上限get_data.py 与 tang_poems.csv 的清洗、分词、切块get_data.py 干的不只是“下载数据”这一件事。它从 data/tang_poems.csv 读进原始数据然后做字段标准化、去重、分词、分块最后把结果交给 build_model.py。很多人看不起这一步实际跑过一遍就会发现召回率上不去八成问题在语料没有治理干净而不在检索模型本身。2.1 CSV 字段设计与读入先回答一个问题为什么用 CSV 而不是数据库。一是课程数据集的天然形态就是 CSV二是几万首诗的体量扔进 pandas 完全不会有内存压力读入、过滤、导出都方便。这套资源里的 tang_poems.csv 字段和大多数唐诗公开数据集接近核心是 title、author、content有的版本还会带 dynasty 或 tags。import pandas as pd df pd.read_csv(data/tang_poems.csv, encodingutf-8) print(df.shape) print(df.columns.tolist()) print(df.isnull().sum())这段代码前三行分别拿到总行数、列名和每一列的缺失数量。唐诗数据经常缺的是 author 字段很多佚名诗被记为 NaN如果后续要按作者检索这里就不能直接 dropna我会把空作者填成“无名氏”。content 为空的行才删除因为正文是检索的主体。字段作用常见脏数据title诗题检索结果展示用首尾带全角空格author作者名按人检索的关键佚名为空content正文分词语料来源换行符不统一清洗时还有一个关键动作按 (title, content) 去重。同一首诗可能在不同来源里重复收录直接保留会造成索引里同一首诗出现多次Top-K 结果被重复项占掉位置。df df.drop_duplicates(subset[title, content]) df[content] df[content].str.replace(\r\n, \n, regexFalse) df df.dropna(subset[content]) df[author] df[author].fillna(无名氏)这里 drop_duplicates 传入的是数组代表“两列同时重复才算重复”replace 把 Windows 风格的回车换行统一成 LF避免后面分句时把 \r 当正文内容fillna 只补作者列不碰正文列。做完这几步语料规模大概能压掉 5% 到 10% 的冗余行。2.2 中文分词与停用词处理中文检索不分词就没法算词频。按单字切会让“月明”语义原子化召回结果散成一地按整句切又没法做词级别匹配所以这一步的标准方案是 jieba 精确模式import jieba stop_words set(line.strip() for line in open(config/stopwords.txt, encodingutf-8)) def tokenize(text: str) - list: words jieba.lcut(text) return [w for w in words if w.strip() and w not in stop_words]参数说明jieba.lcut 返回切好的 list比 cut() 再套 list() 少一次转换开销HMM 参数默认 True对“疑似未登录词”会做二次切分但诗名里的生僻字基本靠这个词本身HMM 帮不上忙。停用词表是这份代码里最容易被低估的文件。唐诗场景里“月”“酒”“花”是高频主题词绝对不能进停用词表真正要滤掉的是“之、乎、者、也、其、以”这类虚词。如果直接抄一份新闻领域的停用词列表会把这些单字主题词全部误杀检索质量断崖式下跌。2.3 索引单元整首诗还是四个句子一组灌进索引的单元不一定是整首诗。整首诗二十到五十字对检索阶段不算长但对后续 RAG 组装上下文会造成一个问题多首诗拼接后 prompt 迅速膨胀而且一首诗里如果只有一联和查询相关整诗作为一个向量会让贡献被无关句子稀释。常见做法是把四句切一个片段共享同一个 poem_iddef split_poem(row): lines [line for line in row[content].split(\n) if line.strip()] chunks [] for i in range(0, len(lines), 4): chunks.append({ poem_id: row.name, title: row[title], author: row[author], text: .join(lines[i:i4]) }) return chunks chunk_list [] for _, row in df.iterrows(): chunk_list.extend(split_poem(row))说明range 的步长 4 对应四句一组slice 取不到越界会自动截断所以最后一组可能是两到三句不影响索引结构。切分完的 chunk_list 通常写成 JSONL 落盘每行一个片段给 build_model.py 直接读取。这一步输出的字段必须和 2.1 里的表头对齐否则后面查问题的时候会多花一倍时间。3. build_model.py 核心剖析TF-IDF 权重、Top-K 召回与双路检索融合build_model.py 负责把语料变成“能回答问题”的索引。这个文件选什么模型、参数怎么定直接决定了后端答案质量的底座。课程作业里最常见的组合是 TF-IDF 加余弦相似度再往上是叠加向量召回的双路设计。3.1 TF-IDF 矩阵生成与参数取舍选 TF-IDF 而不是纯词频核心原因在词频只回答“出现多少次”不回答“这篇文章里出现一次和整个语料出现五十次哪个更有区分度”。TF-IDF 的经典定义是 tf 乘以 ln(N/df)N 是总文档数df 是包含该词的文档数。一个词越“专”df 越小权重越高。诗里最常见的虚词 df 几乎等于 N权重被压到接近零这是它比纯词频稳的原因。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( tokenizertokenize, lowercaseFalse, max_df0.8, min_df2, ngram_range(1, 2), sublinear_tfTrue, ) tfidf_matrix vectorizer.fit_transform(chunk_texts)tokenizer 传入的是 2.2 里定义的自定义分词函数sklearn 内部会先调用它切词再统计词频。max_df0.8 表示出现在 80% 以上文档中的词不进入词表这类词即使不是虚词也几乎没有区分度min_df2 表示只在 1 篇文档里出现过的生僻词直接丢弃避免词表被噪声撑爆ngram_range(1,2) 允许“明月光”这种双词组合进入特征对诗的对仗句很有帮助sublinear_tfTrue 用 1log(tf) 替代原始 tf防止长诗靠长度碾压短诗。矩阵的形状是文档数乘以词表大小几万首诗切块后大概有十万个词稀疏矩阵非零比例通常只有 2% 到 5%占用内存几十 MB 级别课设机器完全跑得动。3.2 查询向量化与 Top-K 排序检索时把用户输入也用同一个 vectorizer 转成向量再和全量矩阵做余弦相似度。注意这里不能重新 fit必须用构建索引时保存下来的 vectorizer否则词表对不上向量维度必然报错。from sklearn.metrics.pairwise import cosine_similarity def search(query: str, top_k: int 10): q_vec vectorizer.transform([query]) scores cosine_similarity(q_vec, tfidf_matrix).flatten() order scores.argsort()[::-1][:top_k] return [ {poem_id: ids[i], score: float(scores[i]), text: texts[i]} for i in order ]cosine_similarity 返回的矩阵只有一行flatten 拉平后每个位置对应一篇文档的相似度。argsort 默认升序加 [::-1] 变成降序再截前 top_k 个下标。scikit 的 resets这段代码跑一次的费用就是一次稀疏矩阵乘法几乎可以做到毫秒级返回。这里有一个容易被忽略的点query 本身没做过语料清洗如果用户输入里带全角空格或繁体字分词后会出现空 token召回质量立刻下降。所以我一般在 search 函数入口对 query 做同样清洗再进分词。3.3 双路检索稀疏召回和稠密召回的融合如果只交 TF-IDF课程作业里算合格但答辩时总会有人问“用户说‘思乡’诗里写‘故园’没有共同词怎么办。”这个问题的解就是双路检索。TF-IDF 负责精确词匹配向量检索负责语义近义两路结果合并后重新排序。召回方式向量形态优点弱点TF-IDF稀疏词袋向量快、可解释、无额外模型同义换词就漏召回向量检索稠密 embedding语义相关能处理换词生僻诗人名效果不稳定向量部分常见的做法是用 sentence-transformers 之类的模型把 query 和文本片段编码成 768 维向量再通过 faiss 建索引。faiss 的 IndexFlatIP 适合小规模精确匹配几万条向量只用 CPU 也能在几十毫秒内返回。def hybrid_search(query: str, w: float 0.6): sparse_hits search(query, top_k50) dense_hits vec_search(query, top_k20) merged {} for rank, hit in enumerate(sparse_hits): merged[hit[poem_id]] { doc: hit, rank: rank, sparse_score: hit[score], dense_score: 0.0 } for rank, hit in enumerate(dense_hits): item merged.setdefault(hit[poem_id], { doc: hit, rank: rank, sparse_score: 0.0, dense_score: hit[score] }) item[dense_score] max(item[dense_score], hit[score]) for item in merged.values(): item[final] w * item[sparse_score] (1 - w) * item[dense_score] return sorted(merged.values(), keylambda x: x[final], reverseTrue)[:10]w 是稀疏召回的权重0.6 意味着更信任 TF-IDF 的精确匹配适合用户输入诗题、作者这类明确实体如果场景偏“找相似意境”可以把 w 调到 0.3 到 0.4。合并时两路的 score 量纲不同需要先各自做 min-max 归一化再加权否则 dense 分数动辄 0.8sparse 分数只有 0.05加权形同虚设。4. llm_query.py 与 client.py 的 RAG 组装上下文裁剪、生成参数与会话改写检索模型返回的是一批诗句片段用户要的却是“李白为什么老写月”这种答案。RAG 这一步把检索结果拼进 prompt让语言模型基于给定证据作答。这一章讲清楚 llm_query.py 怎么组装上下文、怎么控生成参数以及 client.py 怎么处理多轮交互。4.1 为什么检索之后还要再接一层生成如果只给用户看 Top-10 诗从信息检索课的角度已经闭环了。但“导论”作业要想拿高分通常还要回答一句“检索结果如何被消费”。直接丢十首诗给大模型它会把你召回的所有证据都视为事实来源包括那些分数很低、差不多凑数的片段。RAG 的关键不在“接一个大模型”而在把检索结果裁剪成恰好够用的证据块并让生成结果限定在证据范围内。唐诗片段极短一个四句的 chunk 只有二十到五十个字远少于一篇新闻段落所以这里不能像通用文档问答那样只取一个片段而要把多条证据拼接起来同时控制拼接后的总长度。4.2 提示词模板与参数控制llm_query.py 里的核心函数是 build_prompt它负责把证据拼接成适合大模型阅读的格式。这一步对唐诗场景很重要因为诗句本身带题目和作者这些信息要作为出处保留在 prompt 里让模型在回答时能引用具体诗作。def build_prompt(query: str, docs: list, max_length: int 900): evidence [] current 0 for doc in docs: block f《{doc[title]}》[{doc[author]}]{doc[text]}\n if current len(block) max_length: break evidence.append(block) current len(block) prompt ( 你是一个唐诗检索助手。请只依据上面给出的诗句片段回答 不要编造诗句回答不超过120字。\n\n 诗句片段\n .join(evidence) \n问题 query \n回答 ) return prompt这里的 max_length 按字符截断不按 token。一个中文字符在常见 tokenizer 里大约占 0.6 到 1.4 个 token所以 900 字符对应 700 到 1100 token。如果你用的是 512 上下文的轻量模型必须把这个值调到 400 以下否则 prompt 会被静默截断最后几条证据根本到不了模型眼里。参数取值建议作用max_length900 字符控制证据总量按模型窗口调回答上限120 字让模型做摘要而非复读全文top_k5 到 6证据条数太多会让答案变得散生成侧参数同样要压住。我一般用 temperature0.3top_p0.9max_new_tokens200。temperature 太低接近 0会让输出过于机械但唐诗引用要求的是“准确”而不是“有创意”0.3 是个稳定区间。max_new_tokens 200 已经覆盖 120 字的回答上限再大只会让模型在答完之后继续往回收尾句。4.3 client.py 交互层的会话状态与容错client.py 负责把用户请求连到检索和生成链路上。命令行交互最常见的是一个 stdin 循环把 query 送进 search取回 top_k6 的证据再交给 llm_query 生成回答def repl(retriever, llm): history [] while True: query input(请输入问题q 退出).strip() if query in {q, quit}: break try: docs retriever.search(query, top_k6) prompt build_prompt(query, docs) answer llm.generate(prompt) print(检索到:, [d[title] for d in docs]) print(回答:, answer) history.append((query, answer)) except Exception as exc: print(f查询失败: {exc})history 变量虽然记录了对话但唐诗问答场景里把整段历史直接塞进 prompt 会占用大量上下文窗口而收益很小。正确用法是用 history 做查询改写上一轮问“李白”这一轮问“他的代表作”改写后变成“李白的代表作有哪些”再去检索。查询改写本身就是规则函数不需要再调一次大模型简单替换代词和指代即可。这里还要注意retriever.search 返回的结果必须按分数降序build_prompt 按输入顺序拼接下层如果顺序错了模型会认为越靠前的证据越重要答案偏向性会出问题。5. test_model.py 评测实践Hit10、MRR 与三个真实失败样例5.1 评测集与指标怎么定test_model.py 不对外报数字就只是玩具。一个 100 条的评测集至少要覆盖三类问题精确查询给诗名或作者主题查询不给字面词模糊查询只有情绪词。类型模板例子精确查询诗名 作者李白的静夜思主题查询语义换词送别诗里出现杨柳的句子模糊查询只有情绪写孤独的唐诗指标用 Hit10 和 MRR。MRR 是第一个正确答案排名的倒数第一名得 1 分第二名得 1/2没有命中算 0。批量评测时准备好 golden 答案再逐条跑 search计算平均分即可。5.2 三个失败样例与修复方向第一个失败样例返回结果里混入大量现代注释风格文本。原因是 max_df 设成了 0.95虚词全部留在词表里Top-K 被高频词挤占。把 max_df 降到 0.6重训一次索引就能解决。第二个失败样例输入“李白 月”希望返回《静夜思》实际返回《关山月》。两首诗都包含李白和月且词权重接近。修复办法是把 ngram_range 扩到 (1, 3)让“明月光”这种三字词组参与匹配能拉开细微差距。第三个失败样例LLM 照抄整首诗而不是回答问题。问题出在 prompt 约束不足max_new_tokens 给得太大。修复方法是把回答上限从默认值降到 120 字temperature 调到 0.2并在 prompt 末尾重复“基于证据不要复读全文”。这三个样例的调试顺序固定先看召回结果是否准确再调 prompt 和生成参数绝不要跳过召回直接调大模型。本文还有配套的精品资源点击获取
返回列表