你有没有遇到过这种场景:项目 Wiki 里明明写着“用户登录已迁移到 OAuth 2.0 流程”,可你翻代码的时候发现,实际实现早就换成了 JWT 换 token;又或者你在写量化策略的时候,明明记得 Wiki 上有一篇 K 线预处理的踩坑记录,却因为目录太乱找不到入口,只能硬着头皮把代码重写一遍。Wiki 和代码各说各话,不是文档团队偷懒,而是知识载体本身就有代差。后来我搭了一个本地“知识助手”,把 Wiki、代码和零散笔记全部丢进去,用 RAG 的方式让它们互相引用、彼此解释,效果比我预想的好不少。这篇就把我的思路、搭建过程和踩过的坑完整写出来,给同样被文档和代码割裂折磨的人做个参考。
1. 为什么 Wiki 和代码总在“各说各话”
1.1 知识散落的三种典型姿势
我先说说我最常遇到的三种割裂场景,你看看熟不熟悉。
第一种是“文档滞后型”。代码迭代快,接口改了参数,函数换了位置,但 Wiki 里的描述还停在三个月前。写过技术文档的人都知道,文档更新永远赶不上代码演进,尤其是团队没有专职文档工程师的时候,文档靠“谁有空谁维护”,时效性基本靠自觉。我在一个量化策略项目里就吃过这个亏:Wiki 上写着“行情订阅使用 WebSocket 协议”,结果代码早就切到了 gRPC 流式推送,我从头查了两天才发现是文档没跟上。
第二种是“索引错位型”。Wiki 和代码分别存放在不同的系统里,Wiki 里写的是业务概念,比如“订单状态机”,而代码里对应的是OrderStateMachine、OrderEvent、transition()这一堆符号。当我不知道某个业务概念对应哪几个代码文件时,想在 IDE 里全局搜索也无从下手。反过来,从代码找业务文档也一样难,你看到一段诡异的逻辑,想找设计说明,但代码注释只有一两行,Wiki 又不知道入口在哪里。
第三种是“上下文割裂型”。我经常在写代码时有一个疑问,比如“这个服务重试机制是怎么定义的?”,我得先开浏览器查 Wiki,再开 IDE 翻代码,再回 Wiki 找配置说明,来回切换,思路不知道断了多少次。哪怕文档和代码都存在,人也很难同时“阅读”两种介质并建立联系。这种认知负担很隐性,但它每天都在吃掉大量时间。
说白了,Wiki 和代码各自是完整的体系,但“人”需要同时在两者之间来回跳转时,就出现了一条很深的鸿沟。知识助手要解决的不是“再写一份更好的文档”,而是把这两套体系在检索层面打通,让人用一个入口就能同时拿到代码层面的证据和文档层面的解释。
1.2 知识助手到底在解决什么问题
把问题拆开看,知识助手其实要兑现三件事。
第一件是统一的入口。不管我问的是“登录接口怎么调”,还是“为什么这里的重试会卡死”,知识助手都应该给出一个答案,并且这个答案基于本地的 Wiki 文档和代码内容,而不是模型凭空编造的泛化回答。这就天然要求一个带检索的架构,否则直接套大模型很容易出幻觉。
第二件是双向溯源。答案里如果说“订单取消之后会推送OrderCancelEvent”,我希望能直接指向 Wiki 的某个章节、代码的某个文件,让我可以点过去验证。知识助手不能是一个封闭的黑盒,它的定位应该是“带证据的回答”,证据链完整,才有真正的参考价值。
第三件是状态同步能力。我用 Git 管理代码,也希望知识库能跟着代码版本走,至少能让索引定期重建,让 Wiki 和代码的引用关系可追踪。这一点听起来简单,但做起来牵扯到切块粒度、索引更新策略和文档规范,是后面会重点讲的坑。
我做这个项目的时候,最核心的原则就一句话:知识助手不是用来代替人看文档的,而是用来减少人找文档和代码之间对应关系的时间。把检索、溯源、同步这三件事做好,它的价值就已经到位了。
2. 选型思考:为什么是「RAG + 本地知识库」
2.1 RAG 的运行链路
RAG 是 Retrieval-Augmented Generation 的缩写,直译过来就是“检索增强生成”。它的核心思路很简单:大模型天生不知道自己训练完之后出现了什么新内容,也不会知道你本地代码的所有细节,但如果我先从知识库里检索出相关的片段,再把片段和问题一起塞给大模型,它就能“看着资料”回答问题,而不是凭记忆瞎编。
我落地时跑通的链路是这么几段:采集文件、清洗解析、切块、向量化、存储、检索、拼接提示词、生成回答。整体并不复杂,但每一段都有讲究。
采集和清洗解决的是“哪些内容能进知识库”。我本地有一个knowledge目录,里面是 Obsidian 管理的 Markdown 文档、项目源码的说明文档、少量接口规范文本。采集脚本会按扩展名遍历目录,过滤掉.git、node_modules、__pycache__这些中间产物,只保留.md、.py、.js、.txt这几类文本文件。
切块解决的是“按什么粒度理解内容”。原始文档和代码文件可能几千行,直接整篇向量化的话,检索时语义太模糊,而且大模型上下文也塞不下。我把文档按标题层级切开,把代码按函数和类切开,每块限制在几百 token 左右,块与块之间保留少量重叠,避免切断语义。
向量化解决的是“怎么让计算机理解语义相似”。我会把每一块文本通过嵌入模型转成一个高维向量。到这一步之后,知识库就变成了一个向量集合,查询时把用户问题也转成向量,然后通过相似度计算把最相关的几块文本捞出来。
最后一段是生成。把检索到的文本块拼进提示词,交给本地部署的大模型生成最终回答,同时要求在回答中给出对应来源。这样一轮下来,答案就不是凭空产生的,而是有了本地知识库作为支撑。
2.2 为什么不做微调,为什么坚持本地化
有人可能会问:为什么不直接拿私有代码微调一个大模型?我理解这个想法的冲动,但实际做下来,微调在知识库场景里性价比很低,原因有三点。
第一是更新成本。代码和文档一周变好几次,每变一次就微调一次模型,不管算力还是时间都扛不住。RAG 方案里知识库更新就是重新跑一遍索引,几分钟的事情,谁都能操作。
第二是可追溯性。微调后的模型是一个“记忆混合物”,你问它一个具体代码逻辑,它可能记得也可能不记得,就算回答对了,也没有办法给出“我这是根据哪个文件得出来的结论”。但 RAG 的检索结果可以打印出来,每个答案都能追到文档片段和代码文件,这种可验证性对开发类场景极其重要。
第三是冷启动成本。微调一套 7B 模型光是准备数据集就要很久,更别提训练环境。而 RAG 只需要一台能跑嵌入模型的机器,再加一个能聊天的生成模型,就足够起步了。
那为什么又必须强调“本地”?最现实的原因是私有代码不能随便往外发。很多项目的代码库、接口文档、内部 Wiki 都有保密要求,走外部服务意味着核心资产离开内网。放在本地部署,数据和索引全部留在自己的机器上,访问路径可控、访问记录可审计,这才能让人放心用。
另外,本地化还有一个很实际的好处:没有额外费用,不按 token 计费。我自己迭代知识库内容的时候,一天可能跑几十次索引重建和上百条查询,如果用外部 API 的话成本很敏感,本地部署随便折腾不心疼。生成的模型用本地量化模型,嵌入模型用开源向量模型,整条链路完全开源,这也是我坚持本地化的原因。
3. 从零搭建一套本地知识助手
3.1 第一步:把知识库底座收拾齐整
我开始动手前,先干了一件事:把散乱的文档统一迁到 Obsidian 管理的 Markdown 知识库里。为什么选 Obsidian?主要是三方面考虑。一是它默认就是纯文本 Markdown 文件,批量处理没有任何格式障碍;二是支持双链语法[[...]],我可以在 Wiki 里把相关页面串起来;三是它配合 Git 可以做版本管理,知识库的变更历史一目了然。
知识库目录结构我建议先按“项目/主题”分两层,不用一开始就设计得很复杂。比如我本地长这样:
knowledge/ ├── quant-trading/ │ ├── kline-preprocessing.md │ ├── strategy-signal.md │ └── risk-control.md ├── web-service/ │ ├── auth-flow.md │ ├── api-contract.md └── notes/ ├── rag-experience.md └── embedding-models.md这里有个细节值得注意:文档文件名尽量用英文短横线连接,内容里加 frontmatter 元信息,包括tags、updated、related-code。后面构建索引的时候,这些元信息会变成检索结果里的“上下文标签”,方便判断一段内容属于哪个主题、最后更新时间是什么时候、关联哪个代码模块。
还有一个容易被忽略的点:如果你团队用的是在线 Wiki 系统,比如飞书文档或者 Confluence,导出成 Markdown 之后往往带有大量层级结构、表格和注释,清洗时特别容易粘连正文。我的建议是导出后先跑一遍“正文归一化”脚本,把[toc]之类的模板标记删掉,统一缩进和列表格式,否则切块之后会出现大量“空壳块”,检索质量会很难看。
知识库整理完之后,再决定哪些代码目录要纳入范围。我不建议一上来就把整个代码仓库丢进去,因为源码文件太多,切块之后向量库会非常臃肿,检索时还会被大量无关文件干扰。我建议只纳入“源码里和接口、模块边界、业务流程设计相关的部分”,比如服务入口、领域模型、核心工具函数,主要测试文件和编译产物排除掉。
3.2 第二步:采集、切块与向量化
这个环节是整个知识助手的技术核心,也是最容易出现隐性坑的地方。
采集脚本本身不复杂,我用 Python 遍历知识库路径,按扩展名过滤文件,读取的时候统一指定encoding="utf-8",避免中文乱码。对于少量编码不标准的文件,用errors="ignore"兜底,宁可丢弃几个字符,也不能让整个脚本挂在乱码上。
切块策略值得多说两句,这是检索效果好坏的分水岭。文档我按 Markdown 标题层级来切,一般以##和###为切分点,这样切出来的每一块天然就是一个主题段落,有明确标题,语义集中。代码则按函数和类来切,用简单的正则匹配def、class行作为切分边界。
至于块的大小,我踩过几轮后定下来一个经验区间:Markdown 文档每块控制在 500 token 左右,代码块控制在 200-300 token。token 数有个粗估方法:英文大概一个词算一个 token,中文一个字算一到两个 token,500 token 大约对应几百字的中文段落。块太大了向量表示会被无关内容冲淡,块太小又会切断函数内部的完整逻辑,导致上下文不连贯。
块与块之间我设置了 10%-15% 的重叠,也就是 50-80 token。这个重叠很重要,因为很多关键技术信息恰好出现在段落交界处,如果两边都不保留,检索时就会漏掉。别小看这个细节,我刚开始没加 overlap,结果“如何初始化连接池”这类问题总是检索不到答案,因为答案正好横跨两个块。
向量化模型的选择直接影响匹配精度。我做过几组对比,中文场景下效果排序大概是BAAI/bge-m3>moka-ai/m3e-base> 通用英文 embedding 模型。bge-m3 对中文的长文本语义理解明显更好,维度也足够表达复杂关系。如果只是做英文技术文档,sentence-transformers/all-MiniLM-L6-v2就够用,性能开销也小。
向量库我用的是 Chroma,原因是部署简单,数据持久化到本地一个文件夹,重启不丢;查询接口也足够支撑个人使用。如果数据量小,也可以用 FAISS,但 FAISS 没有内置的持久化和元数据过滤,起步阶段我更推荐 Chroma。
我把核心的索引构建脚本贴在这里,细节我已经实际跑过,可以直接参考。注意根据自己的目录结构微调。
import os from pathlib import Path from langchain.text_splitter import ( MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter, ) from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma KNOWLEDGE_DIR = Path("./knowledge") DB_DIR = "./chroma_db" MODEL_NAME = "BAAI/bge-m3" def collect_files(root: Path): """收集 Markdown、Python、JS、TXT 文件,过滤掉中间产物。""" candidates = [] for ext in ("*.md", "*.py", "*.js", "*.txt"): candidates.extend(root.rglob(ext)) return [ p for p in candidates if ".git" not in str(p) and "node_modules" not in str(p) and "__pycache__" not in str(p) ] def split_markdown(text: str): splitter = MarkdownHeaderTextSplitter( headers_to_split_on=[("##", "h2"), ("###", "h3")] ) return splitter.split_text(text) def split_code(text: str): splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, separators=["\ndef ", "\nclass ", "\n\n", "\n", " ", ""], ) return splitter.split_text(text) def build_index(): docs = [] for path in collect_files(KNOWLEDGE_DIR): text = path.read_text(encoding="utf-8", errors="ignore") if path.suffix == ".md": chunks = split_markdown(text) # MarkdownHeaderTextSplitter 返回 Document 对象,带 heading 元数据 for chunk in chunks: chunk.metadata["source"] = str(path) docs.append(chunk) else: chunks = split_code(text) for chunk in chunks: docs.append({"source": str(path), "text": chunk}) print(f"{path.name}: {len(chunks)} chunks") if not docs: print("没有采集到任何文件,请检查目录路径。") return embeddings = HuggingFaceEmbeddings(model_name=MODEL_NAME) # 代码类的 chunk 结构不一样,简单处理时统一取 text texts = [d.page_content if hasattr(d, "page_content") else d["text"] for d in docs] metadata = [d.metadata if hasattr(d, "metadata") else d for d in docs] db = Chroma.from_texts( texts=texts, embedding=embeddings, metadatas=metadata, persist_directory=DB_DIR, ) db.persist() print(f"索引构建完成,共 {len(texts)} 个文本块。") if __name__ == "__main__": build_index()这段脚本我建议放在知识库根目录的外面,避免索引脚本本身也被当成知识文件。运行时如果是第一次下载bge-m3模型,需要联网拉取模型权重,之后会在本地缓存,后续构建索引不会再消耗网络带宽。这一步完成后,知识库向量索引就生成了,查询阶段可以直接复用。
3.3 第三步:接入模型,跑通问答闭环
索引建好之后,下一步就是把检索和生成串起来。生成模型我用的是 Ollama 本地跑的qwen2.5:14b,14B 的参数规模在普通开发机上能跑起来,效果也比 7B 明显好一截。如果机器只有 16G 内存,可以先从 7B 量化版本起步,体验链路,再逐步升级。
Ollama 的安装很简单,装完之后拉取模型就能用了。注意第一次拉模型会花费一些时间,因为需要把完整的模型文件下载到本地,之后所有推理都在本地完成,不依赖外部服务,也不会产生按 token 计费的问题。
我平常用下面这个命令拉取模型:
# 拉取默认的 qwen2.5 14B 版本 ollama pull qwen2.5:14b # 查看本机已有的模型 ollama list查询端的核心逻辑也不复杂:先用相同的 embedding 模型把用户问题转成向量,在 Chroma 里做相似度检索,取出最相关的 5-8 个知识块,然后把这些知识块和问题拼成一个提示词,扔给 Ollama 生成回答。
这里有个特别重要的细节:检索出的片段不能全部塞进去,因为生成模型的上下文窗口有限,而且无关片段越多,模型越容易跑偏。我一般先取 top 20 个候选块,再用一个重排器粗略过滤一遍,最终保留 5 个最相关的。重排我用的是bge-reranker-base,它对中文支持不错,而且重排的计算量比向量检索大,所以只处理候选集,数量不大,可以接受。
下面是我一直在用的查询脚本核心部分,纯 Python 实现,依赖requests和刚才建好的 Chroma 索引。
import requests from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma DB_DIR = "./chroma_db" EMBED_MODEL = "BAAI/bge-m3" OLLAMA_URL = "http://localhost:11434/api/chat" OLLAMA_MODEL = "qwen2.5:14b" def retrieve(question: str, k=8): embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL) db = Chroma(persist_directory=DB_DIR, embedding_function=embeddings) results = db.similarity_search_with_relevance_scores(question, k=k) return results def ask(question: str): results = retrieve(question) context_blocks = [] for doc, score in results: if score < 0.3: continue source = doc.metadata.get("source", "未知来源") context_blocks.append(f"[来源: {source}]\n{doc.page_content}") context = "\n\n".join(context_blocks) prompt = ( "你是一个本地知识助手。回答问题时,必须优先基于下面检索到的资料。" "如果资料中没有答案,请直接说明知识库中未找到,不要编造。\n\n" f"检索资料:\n{context}\n\n" f"问题: {question}\n\n" "请给出基于资料的回答,并在回答后列出引用的来源。" ) resp = requests.post( OLLAMA_URL, json={ "model": OLLAMA_MODEL, "messages": [{"role": "user", "content": prompt}], "stream": False, }, timeout=120, ) return resp.json()["message"]["content"]阈值0.3是我根据向量模型实际输出调的,不同模型下分数含义不同,建议先打印几组分数观察分布再定。如果检索出来的片段分数普遍很低,说明知识库里确实没有相关内容,这时候与其强行生成,不如明确提示“未找到”。
整个链路跑通后,我会在终端里用一个简单脚本接收问题,直接跑ask("登录接口的鉴权流程怎么实现?")就能看到回答和来源。我实际用下来的体验是,对于“某个模块的职责是什么”“这段代码为什么会这么写”这类问题,回答质量已经能到“能直接用来定位问题”的程度。
4. 实操中的常见问题与排查实录
4.1 检索结果不准,该怎么调
这是我最常被问到的问题,也是我自己踩得最深的一个坑。检索不准时,第一步不是换模型,而是先搞清楚问题出在召回端还是生成端。
我的排查方法是:先不看生成结果,单独打印检索出来的文本块,看看这些块和问题是不是真的相关。如果检索出来的块本身就不相关,那就是召回的问题;如果检索到的块相关但生成答案跑偏,那就是生成提示词的问题。这两种问题的解决路径完全不同,混在一起调试只会浪费时间。
召回端的问题,优先检查三件事。第一是切块粒度,块太大会稀释语义,块太小会丢失逻辑上下文,这是绝大多数“检索不准”的根源,回到 3.2 节把 chunk size 调一遍往往比换模型更有效。第二是 embedding 模型对专业词汇的适应度,比如代码里的英文标识符混着中文描述时,通用的中文 embedding 模型可能识别不好,这时候可以试试在文本块里保留原始代码标识符,或者在bge-m3和m3e-base之间切换对比。第三是 top_k 大小,k 太小会漏掉正确答案,k 太大会混入噪声,我个人常用检索 20 个、重排后取 5 个。
如果问题出在生成端,更常见的原因是提示词里没有明确约束“必须基于检索内容回答”。模型在无约束情况下会自作聪明地动用内部知识,反而把正确答案冲淡了。我现在的提示词固定加了一句“如果资料中没有答案,请直接说明知识库中未找到”,效果立竿见影,幻觉明显减少。
4.2 中文内容被截断或乱码
中文是知识库场景里绕不开的坎,尤其是原本来自飞书文档、Confluence 导出的内容。乱码通常发生在文件读取阶段,也就是没有统一指定utf-8编码。但还有一个更隐蔽的问题:切分器对中文的分句能力不足。
英文有空格作为天然分词边界,RecursiveCharacterTextSplitter能利用空格切出比较合理的块。但中文没有空格,如果分隔符列表里不加入中文句读,切出来的块可能是“一句话被从中间劈开”,既破坏语义又影响向量表达。
我的解决办法是在分隔符列表里加上中文标点:
separators=[ "\n## ", "\n### ", "\ndef ", "\nclass ", "\n\n", "。", "!", "?", "\n", " ", "" ]这么改完之后,中文段落的切分边界按照句子级别断开,语义完整性会好很多。另外还有一个细节:某些 Wiki 导出文本里带有全角空格、不间断空格\u00a0,这类字符肉眼看不见,但会影响后续向量化效果,建议在清洗阶段统一替换成普通空格。
4.3 本地模型生成太慢怎么办
本地模型的速度和硬件强相关,尤其是生成环节,14B 模型在没有独立显卡的机器上可能每秒钟只能产出几个 token,体验比较差。我的做法是分清场景:检索和重排永远用最轻量的模型,只有最终生成才用大模型,而且如果只是想让搜索更高效,很多情况下连生成都不需要,直接看检索结果就够了。
如果确实需要生成,但速度太慢,有三个方向可以优化。第一是换更小参数的量化版本,比如从 14B 换成 7B 或 4B 量化版,回答质量会略降,但速度收益很大。第二是裁剪上下文,限制检索片段的数量,减少输入 token,生成速度会明显提升,因为输入阶段的计算量也被算进去了。第三是提前预判,如果某些问题是高频的、可枚举的,比如“某接口的请求参数是什么”,我宁可直接做一个结构化查询,而不是每次都走大模型生成,这样响应速度能压到毫秒级。
内存方面也要留意,14B 量化模型加载后大约占 8-10G 内存,加上向量索引和 embedding 模型,整机 16G 内存会显得紧张。如果发现系统卡顿,优先把供养度低的模型替换成更小版本,或者把 Chroma 索引放到更快的磁盘上。
4.4 Wiki 与代码的版本同步问题
这个问题的源头不在技术,而在工作流。知识助手能索引的是“过去某个时刻的快照”,如果代码改了但 Wiki 没更新,索引再准也是引用旧知识。这也解释了为什么我前面强调用 Git 管理知识库:把文档和代码放进同一个版本体系后,至少可以做到“谁知道改了什么、谁该为同步负责”。
我的做法是给每篇 Wiki 文档的 frontmatter 强制加上updated字段,并在代码合并请求的模板里增加一项“是否涉及文档更新”,如果涉及,开发者就必须把相关 Wiki 页面一起改掉。这一步相当于把“文档同步”从自觉行为变成流程约束,比任何技术手段都可靠。
索引重建的频率建议和代码提交频率挂钩。我本地用脚本监听 Git 提交事件,每次提交后自动对变更的目录重新跑一遍索引构建,只更新增量部分,几秒钟就完成。如果团队里不想跑脚本,也可以定一个“每天凌晨全量重建”的 cron 任务,代价是几分钟的构建时间,换来知识库的即时性。这里的关键是别让索引更新变成一个“想起来才做”的事,否则知识助手很快又会退化成又一份没人看的旧文档。
5. 从「能用」到「好用」的几条进阶经验
5.1 知识卡片化,让索引更可靠
一开始我的知识库是整篇整篇的长文档,检索效果只能说勉强可用。后来我把核心知识点拆成“知识卡片”,每张卡片解决一个问题,卡片头部带元信息,卡片内容用标准结构组织,效果立刻不一样了。
知识卡片的范式大概长这样:
--- title: K线预处理标准流程 tags: [quant, kline, preprocessing] updated: 2024-06-15 related-code: data_processor.py, indicator_engine.py --- # K线预处理标准流程 ## 问题背景 原始行情数据包含停牌、除权、复权导致的跳变,直接用于策略回测会产生偏差。 ## 处理流程 1. 按合约代码分组 2. 检查复权因子,优先使用前复权 3. 过滤成交量异常为零的K线 4. 写入预处理结果缓存 ## 关键代码索引 - 复权逻辑见 `data_processor.py` 中的 `adjust_factor()` 函数 - 过滤器见 `indicator_engine.py` 中的 `filter_zero_volume()`这种结构对 RAG 特别友好。原因很简单:切块时每一张卡片都是一个完整的语义单元,检索出来就是“自解释”的一段内容;related-code字段让知识助手在后台建立“文档-代码”关联索引,回答时可以顺便指明去看哪个文件的哪个函数。
写卡片很麻烦,但我发现日常维护成本没有想象中高。我一般是在写代码的时候顺手写卡片,一个功能模块写完,对应卡片也出来了,比事后补文档轻松得多。而且卡片粒度下,文档更新范围也变小了,不会出现“改一句话要重读三页”的心理负担。
5.2 给代码建一份引用档案
知识助手可以自动建立代码的引用档案,让文档和代码的对应关系可视化、可跳转。工作原理不复杂:在文档的related-code里写入相对路径,索引脚本解析后形成一张“文档 → 源码文件”的映射表。查询时如果检索到文档块,就顺带把对应的源码路径展示出来;如果检索到代码块,就反向把代码所在模块对应的 Wiki 链接展示出来。
我在本地还把这份映射导出成一个 HTML 页面,点一下文档名就能跳转到源码文件位置,点一下代码文件就能看到相关文档。从 Obsidian 侧则启用一个链接插件,在 Markdown 预览视图里渲染出可点击的代码引用。效果就是:看 Wiki 时能看到代码位置,看代码时能想起相关 Wiki,知识助手在中间充当索引中枢。
这个能力听起来有点像 IDE 的“查找引用”,但它的价值不只是跳转,而是把“业务概念”和“实现符号”这两套命名体系联系起来。比如业务文档里写“熔断降级”,而代码里是CircuitBreaker,检索时只要能命中其中一个,就能顺手把另一个引出来,这才是真正意义上的知识融合。
5.3 混合检索与反馈闭环
向量检索擅长语义相近的问题,但代码场景里有一个天然优势是关键词精确匹配:搜def train()这种精确符号时,关键词检索往往比向量检索更直接。所以进阶阶段我引入了混合检索,把 BM25 精确匹配的分数和向量相似度分数做加权融合,显著提升了含代码符号的查询命中率。
混合检索不需要复杂实现,我直接用rank_bm25库对同一批候选文本块跑一遍关键词打分,然后按“0.7 向量分 + 0.3 关键词分”加权排序。调权重时有几条经验:如果团队大量用中文描述问题,向量分权重要高;如果经常搜英文函数名、类名、错误信息,关键词分权重要高。
反馈闭环是一个可以让知识助手越用越准的机制。我在查询脚本里加了一个记录函数,每次查询后把问题和最终采用的文档块存到本地 sqlite 数据库。每周末我会跑一个小脚本,统计哪些文档块被频繁引用,哪些文档块从没被检索到,后者我会检查是不是内容过期了。这样做还有一个额外收获:可以观察团队真正问得最多的问题集中在哪个模块,反向驱动文档补全。
说到底,知识助手不是我写完索引和提示词就撒手不管的东西,它需要持续喂养和调优。但正因为有了这一步,Wiki 和代码才真正从“两套系统”变成了“一个可以对话的知识体”,这也是我搭完这套东西之后最有成就感的地方。