
1. 为什么我要在本地折腾一个感情智能助手先说结论我搭这个本地 RAG 感情智能助手核心动机只有一个——隐私。感情这件事你跟朋友吐槽可以但把聊天记录、日记、情绪波动上传到某个云端大模型去分析我心理上过不去。而且云端 API 按 token 计费长期高频使用成本不低断网就歇菜模型版本还可能悄悄变。本地部署之后数据不出机器想怎么问就怎么问半夜三点情绪崩溃也能立刻用。这个助手能做什么简单说它把你自己积累的情感类文本——聊天记录、日记、备忘录、看过的情感文章、心理学笔记——做成一个可检索的知识库然后用本地大模型基于这些内容回答你的问题。比如我最近为什么总因为小事跟伴侣吵架上次类似情况我是怎么走出来的帮我分析一下这段对话里双方的情绪走向。它不会给你网上那种放之四海皆准的鸡汤而是基于你自己的历史给反馈。适合谁参考三类人一是对隐私敏感、不想把私人文本上云的人二是手上有闲置显卡或一台还算能打的电脑想玩本地大模型的人三是已经用过云端 RAG 但被检索不准、答非所问折磨过想搞清楚混合检索和重排序到底怎么落地的人。我这次的技术栈是 Ollama 跑本地模型 向量检索 BM25 关键词检索 重排序也就是热词里反复出现的混合检索路线。需要提前说明的是下面涉及的具体参数、目录结构、代码片段都是我在自己机器上跑通后整理的属于常见实践下的合理方案不是唯一解。你的硬件、系统、数据形态不同细节要相应调整。但整体思路和踩坑点是可以直接复用的。2. 本地部署的硬件账与模型选型别一上来就冲大参数2.1 显存和内存到底怎么算很多人一上来就问我 16G 显存能不能跑 70B这个问题本身就问错了。本地部署大模型你要同时装下三样东西模型权重、KV Cache、以及检索链路里的嵌入模型和重排序模型。RAG 场景比纯聊天更吃资源因为你不是只跑一个模型。先给一个粗略的估算公式。模型权重的显存占用量化到 4bit 时大约是参数量 × 0.5 字节8bit 约参数量 × 1 字节。也就是说模型规模4bit 量化权重8bit 量化权重建议最低显存含 KV Cache 余量7B约 4GB约 7GB8GB8B约 4.5GB约 8GB8-10GB14B约 8GB约 14GB12-16GB32B约 18GB约 32GB24GB 以上70B约 40GB约 70GB多卡或 48GB 以上KV Cache 这块容易被忽略。它跟上下文长度成正比你如果要把一整段长对话塞进去上下文开到 8K、16KKV Cache 可能吃掉好几个 G。我自己的经验是16G 显存跑 14B 的 4bit 量化模型 8K 上下文是比较舒服的甜点区再往上就要精打细算了。嵌入模型和重排序模型相对轻量。嵌入模型像 bge-m3、nomic-embed-text 这类几百 MB 到 1G 多重排序模型像 bge-reranker 系列也是几百 MB 级别。它们可以放 CPU 跑只是慢一点放 GPU 上更快。2.2 模型选型的取舍逻辑我选模型看三个维度中文能力、指令遵循、资源占用。感情类文本分析对中文语义理解要求高纯英文强的模型不一定合适。主力对话模型我倾向 7B-14B 区间的中文友好模型量化用 Q4_K_M。这个量化等级在质量和体积之间平衡得最好Q4_0 太糙Q8 又太占地方。嵌入模型bge-m3 是我用得最顺的它同时支持稠密检索、稀疏检索和多向量中文表现稳而且一个模型能顶多个用。重排序模型bge-reranker-v2-m3跟嵌入模型同源配合起来省心。这里有个反直觉的点不是模型越大效果越好。在 RAG 场景里检索质量对最终答案的影响往往比模型参数量更大。你用一个 70B 模型去回答一堆检索错的上下文它只会更自信地胡说。所以我的资源分配策略是模型够用就行把省下来的算力留给检索和重排序。2.3 Ollama 的安装与模型拉取Ollama 是目前本地跑模型最省心的方案之一跨平台命令行友好。安装我就不贴官网步骤了直接说拉模型# 拉取对话模型这里以常见的 7B 中文模型为例 ollama pull qwen2.5:7b-instruct-q4_K_M # 拉取嵌入模型 ollama pull bge-m3 # 查看本地已有模型 ollama list # 测试模型是否能正常响应 ollama run qwen2.5:7b-instruct-q4_K_M 你好简单介绍一下你自己拉完之后Ollama 默认在11434端口提供 HTTP 接口后面我们的 RAG 程序就通过这个接口调用。有个细节要注意Ollama 默认的上下文长度可能比你想象的短如果你要喂长文本得在 Modelfile 里调num_ctx参数否则超出部分会被静默截断检索回来的内容等于白搭。# 创建一个自定义 Modelfile调大上下文 cat Modelfile EOF FROM qwen2.5:7b-instruct-q4_K_M PARAMETER num_ctx 8192 PARAMETER temperature 0.7 EOF ollama create my-emotion-model -f Modelfiletemperature我设 0.7感情分析不需要太天马行空但也不能太死板0.7 是个折中。如果你要它做严谨的情绪归类可以降到 0.3。3. 混合检索才是 RAG 的命门纯向量检索为什么不够用3.1 纯向量检索在感情文本上的翻车现场我一开始也是纯向量检索把文本切块、嵌入、存向量库查询时算余弦相似度取 Top-K。跑了一段时间发现两个典型问题。第一个问题是专有名词和具体事件检索不准。比如我问我和小林那次关于搬家计划的争执向量检索可能返回一堆搬家争执语义相近但跟小林无关的段落。因为向量是把语义压缩成稠密向量的具体的人名、时间、地点这些精确信号在压缩过程中被稀释了。第二个问题是短查询效果差。感情类问题经常很短比如我是不是太敏感了这种查询本身信息量少向量检索容易漂移。3.2 BM25 补上了哪块短板BM25 是经典的关键词检索算法它基于词频和逆文档频率打分。它的强项恰恰是向量检索的弱项精确匹配关键词。你查小林它就找含小林的段落你查搬家计划它优先返回同时含这两个词的文本。BM25 的核心思想可以这样理解一个词在当前文档里出现越多这篇文档越相关词频 TF但这个词如果在所有文档里都很常见那它的区分度就低权重应该下降逆文档频率 IDF同时文档越长词频的绝对数值越容易虚高所以要归一化。公式不展开你只要记住它的性格认字不认意精确但不懂语义。3.3 混合检索的融合策略混合检索就是把向量检索和 BM25 的结果合起来。融合方式主要有两种加权求和给两路结果各配一个权重比如向量 0.7、BM25 0.3加权后重新排序。简单直接但权重要调。RRF倒数排名融合不看具体分数只看排名。某文档在向量检索里排第 3、在 BM25 里排第 5就用1/(k排名)累加k 通常取 60。RRF 的好处是不需要归一化两路分数因为向量相似度和 BM25 分数根本不在一个量纲上直接加权容易出问题。我最终用的是 RRF因为它省去了调权重的麻烦鲁棒性更好。下面是我检索部分的核心逻辑def hybrid_retrieve(query, top_k20, rrf_k60): # 向量检索 vector_results vector_search(query, top_ktop_k) # BM25 检索 bm25_results bm25_search(query, top_ktop_k) # RRF 融合 scores {} for rank, doc in enumerate(vector_results): scores[doc.id] scores.get(doc.id, 0) 1.0 / (rrf_k rank 1) for rank, doc in enumerate(bm25_results): scores[doc.id] scores.get(doc.id, 0) 1.0 / (rrf_k rank 1) # 按融合分数排序 ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [doc_map[doc_id] for doc_id, _ in ranked[:top_k]]这里top_k我取 20也就是每路先各召回 20 条融合后再交给重排序。为什么召回这么多因为重排序模型的能力有限你给它太少候选它没得挑给太多又慢。20 是个经验值。提示BM25 需要先对文本做分词。中文分词我推荐 jieba感情文本里口语化表达多jieba 的精确模式基本够用。如果你数据里有大量网络用语可以自己维护一个自定义词典。4. 重排序这一步决定了答案的准头4.1 重排序到底在排什么混合检索召回 20 条之后这些结果的相关性排序还是粗排。重排序模型Reranker是一个交叉编码器它把查询和候选文档拼在一起送进模型直接输出一个相关性分数。跟向量检索的双塔结构不同交叉编码器能让查询和文档在模型内部充分交互所以精度高得多代价是慢——它必须对每个候选逐一计算没法像向量那样预先建索引。打个比方向量检索像图书馆按主题分区找书快但粗重排序像把候选的几本书拿下来一本本翻目录看是不是你真要的慢但准。所以标准流程是粗排召回 精排重排。4.2 重排序的实操配置from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-v2-m3, use_fp16True) def rerank(query, candidates, top_n5): pairs [[query, doc.text] for doc in candidates] scores reranker.compute_score(pairs, normalizeTrue) # 按分数排序取前 top_n ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [doc for doc, _ in ranked[:top_n]]use_fp16True能省一半显存精度损失可忽略。top_n我取 5也就是最终喂给大模型的上下文是 5 段。为什么是 5因为上下文太长会稀释关键信息还会挤占 KV Cache。5 段在信息足够和不超长之间比较平衡。4.3 一个容易忽略的坑重排序的输入长度重排序模型也有最大输入长度限制通常是 512 或 1024 token。如果你切块切得太大一段就 800 字加上查询可能超限超出的部分会被截断导致重排序看不全文档。我的做法是切块控制在 300-500 字这样重排序能完整看到每段内容。切块还有个策略问题固定长度切块简单但会把一句话拦腰截断。我推荐按语义边界切比如按段落、按对话轮次切。感情类文本尤其如此一段完整的情绪表达被切碎检索出来就是断章取义。我用的切块参数是目标长度 400 字相邻块重叠 80 字保证边界信息不丢。5. 从零把整条链路串起来目录、流程与关键代码5.1 项目目录结构我习惯把项目拆得清清楚楚方便后面替换组件emotion-rag/ ├── data/ │ ├── raw/ # 原始文本聊天记录、日记等 │ └── processed/ # 清洗切块后的数据 ├── index/ │ ├── vector/ # 向量索引 │ └── bm25/ # BM25 索引 ├── src/ │ ├── ingest.py # 数据入库 │ ├── retrieve.py # 混合检索 │ ├── rerank.py # 重排序 │ └── chat.py # 对话主流程 ├── config.yaml └── requirements.txt5.2 数据入库清洗比想象中重要感情类原始数据脏得很聊天记录有大量表情、语音转文字的错误、重复消息日记有错别字、口语缩写。入库前必须清洗否则脏数据会污染整个检索。import re def clean_text(text): # 去掉多余空白 text re.sub(r\s, , text) # 去掉纯表情符号占位按你的数据实际情况调整 text re.sub(r\[.*?\], , text) # 去掉连续重复字符比如啊啊啊啊 text re.sub(r(.)\1{3,}, r\1\1, text) return text.strip()清洗完再切块、嵌入、建索引。嵌入我用 Ollama 的 bge-m3import requests def get_embedding(text): resp requests.post( http://localhost:11434/api/embeddings, json{model: bge-m3, prompt: text} ) return resp.json()[embedding]向量索引我用的 FAISS轻量、快、纯本地适合个人规模的数据。如果你的数据到了几十万条以上可以考虑 Milvus 这类专业向量库但个人感情助手通常用不上。5.3 对话主流程把前面所有环节串起来主流程就是查询改写 → 混合检索 → 重排序 → 拼上下文 → 调大模型。def chat(user_query): # 1. 混合检索召回 candidates hybrid_retrieve(user_query, top_k20) # 2. 重排序精排 top_docs rerank(user_query, candidates, top_n5) # 3. 拼上下文 context \n\n.join([d.text for d in top_docs]) # 4. 构造提示词 prompt f你是一个基于用户个人历史的情感分析助手。 请严格依据下面的历史片段回答不要编造历史中没有的内容。 如果历史片段不足以回答请直接说明。 历史片段 {context} 用户问题{user_query} # 5. 调用本地大模型 resp requests.post( http://localhost:11434/api/generate, json{model: my-emotion-model, prompt: prompt, stream: False} ) return resp.json()[response]提示词里那句不要编造历史中没有的内容很关键。RAG 最大的风险就是模型拿着检索到的片段自由发挥把不存在的事说得跟真的一样。明确约束能显著降低幻觉。6. 实测中踩过的坑和调优心得6.1 检索回来一堆看起来相关但没用的内容这是最常见的抱怨。原因通常有三个切块太大导致一段里混了多个主题嵌入模型不适合你的领域Top-K 取太大把噪声也召回了。我的排查顺序是先看切块再看嵌入模型最后调 Top-K。八成问题出在切块上。感情文本主题切换频繁一段 800 字里可能聊了三件事检索命中其中一件整段都被召回另外两件就成了噪声。6.2 重排序之后反而变差了听起来反直觉但确实会发生。原因一般是重排序模型和嵌入模型不匹配或者重排序的输入被截断了。我遇到过重排序把真正相关的段落排到后面查下来是那段文本超过了 512 token 被截断模型只看到了前半段。解决办法就是前面说的控制切块长度。6.3 大模型答非所问明明上下文里有答案这种情况多半是提示词的问题。模型没被告知只能用上下文回答它就会用自己的先验知识。另外上下文里如果有相互矛盾的信息模型会困惑。我的做法是在提示词里明确优先级并且把最相关的片段放在最前面——模型对开头和结尾的内容注意力更高这是中间迷失现象的应对。6.4 响应太慢本地部署的痛。优化方向模型量化等级降一档、上下文长度别开太大、重排序候选数减少、嵌入和重排序能上 GPU 就上 GPU。我实测下来7B 模型 20 候选重排 5 段上下文一次完整问答在十几秒量级可以接受。如果你追求更快可以把重排序关掉只用混合检索质量会降一点但速度明显提升。6.5 数据更新了索引怎么同步个人助手的数据是持续增长的今天写的日记明天就想能检索到。我的做法是增量入库新文本清洗切块后追加到向量索引和 BM25 索引不需要重建全量。FAISS 支持add操作BM25 索引重建成本也不高。但要注意增量入库后如果嵌入模型换了旧向量就废了必须全量重建。所以嵌入模型一旦定下来别轻易换。7. 关于知识库形态的一点延伸思考热词里出现了kg 知识库结构知识库ontology rag这些概念我顺便说说我的理解。纯文本 RAG 是扁平的所有知识都是一段段文本检索靠相似度。而知识图谱或结构化知识库是立体的它把实体和关系显式建模出来比如小林—是—我的伴侣搬家计划—引发—争执。两者适用场景不同。扁平 RAG 适合开放式的、语义模糊的查询比如我最近情绪怎么样结构化知识库适合精确的关系查询比如和我发生过争执的人都有谁。感情助手其实两者都需要日常情绪分析用扁平 RAG人物关系梳理用结构化。我目前的方案是扁平为主后续打算把人物、事件抽出来建一个轻量图谱作为检索的补充信号。这块我还在摸索等跑通了再单独写。本地部署 RAG 感情助手这件事技术门槛没有想象中高但细节特别多。真正决定体验的不是模型多大而是检索准不准、切块合不合理、提示词约束够不够。我踩过的坑基本都写在上面了你照着搭能少走不少弯路。