☰
基于InternLM与LangChain构建知识库问答系统:从选型到部署的工程实践
2026/10/28 20:00:26 网站建设 项目流程

知识库问答这个方向,从大模型火起来那天起就没冷过。我最早接触这类需求,是帮一个做内部培训的朋友处理他们那堆散落在各个共享目录里的制度文档——几百份文件,新员工问一个问题,老员工得翻半天。当时试过直接拿通用大模型硬答,结果它一本正经地编造条款,差点闹出笑话。后来转向检索增强生成这条路,才算把"答得准"这件事落地。这次要聊的,是基于 InternLM 和 LangChain 搭一套能跑起来、能维护、能扩展的知识库系统。它解决的核心问题很朴素:让模型基于你自己的文档回答问题,而不是靠它脑子里那点可能过时、可能张冠李戴的预训练记忆。适合谁看?如果你手上有成堆的 PDF、Word、Markdown,想让它们变成"能对话"的资产,又不想从零造轮子,那这套组合拳值得跟着走一遍。下面我按实际搭建的顺序,把选型逻辑、踩过的坑、能直接抄的配置都摊开讲。

1. 为什么是 InternLM 加 LangChain 这个组合

动手之前先把选型想清楚,比写完一堆代码再推倒重来划算得多。这套组合不是随便凑的,它对应着知识库系统里两个截然不同的职责:一个是"大脑",负责理解和生成;一个是"骨架",负责把文档、检索、模型串成流水线。

1.1 模型侧:为什么选 InternLM 而不是随便找个通用模型

知识库问答对模型有个很实际的要求——它得能"听话"。所谓听话,是指模型要老老实实基于检索回来的上下文作答,而不是自顾自地发挥。InternLM 系列在这个点上表现比较稳,尤其是它的对话版本对中文语料的适配做得扎实,处理中文文档里的长句、专业术语、表格残留文本时,不会动不动就断章取义。

另一个现实考量是部署成本。知识库系统往往要处理敏感的内部文档,很多场景不适合把数据往外送,本地化部署就成了刚需。InternLM 提供了从轻量到较大的多个规格,7B 级别的模型在单张消费级显卡上就能跑起来,量化之后显存占用还能再压一截。我实测过 7B 的对话模型做问答,只要检索质量过关,回答的可用性完全够日常使用。你要是追求更强的推理能力,可以上更大规格,但那就得掂量硬件了。

还有一点容易被忽略:InternLM 的生态工具链比较完整,配套的部署框架、微调脚本、量化方案都有现成参考,这对想长期维护的项目很关键。选模型不只是选权重,更是选一整套周边支持。

1.2 框架侧:LangChain 到底帮你省了什么

很多人第一次看 LangChain 的文档会觉得它抽象层太多,一个简单的"读文档、切块、检索、回答"被包装成一堆 Chain 和 Retriever。但真上手搭过完整系统之后,你会发现它省掉的是那些琐碎又容易出错的胶水代码。

举个具体的:文档加载。你的知识库可能混着 PDF、Word、纯文本、网页存档,每种格式的解析方式都不一样,PDF 还有扫描版和文字版的区别。LangChain 的 Document Loader 把这些差异统一成一个接口,你换格式只需要换一个 Loader 类。再比如文本切分,切多大切多小、重叠多少,直接影响检索命中率,它内置的递归切分器已经处理好了按段落、按句子边界回退的逻辑,比你自己写正则靠谱。

更关键的是检索链的组装。向量库检索、关键词检索、结果重排、上下文拼装、提示词模板、模型调用,这一串环节如果手写,光是调试各环节之间的数据格式就能耗掉几天。LangChain 用 LCEL 把这些环节声明式地串起来,改一个环节不影响其他部分。当然它也有代价——抽象层会掩盖一些细节,出问题时排查链路变长,这个后面会专门讲怎么应对。

1.3 两者的衔接点在哪里

InternLM 和 LangChain 的对接,本质上是把 InternLM 包装成一个符合 LangChain 接口规范的 LLM 对象。LangChain 不关心你背后是哪个模型,它只要求你提供一个能接收消息列表、返回文本的调用入口。InternLM 官方提供了兼容 OpenAI 接口规范的服务方式,这就让对接变得非常顺——你既可以用 LangChain 的 OpenAI 兼容封装直接连,也可以自己写一个轻量的自定义 LLM 类。

我倾向于后者,原因很简单:自己写的封装能精确控制超时、重试、流式输出的处理逻辑,出问题时一眼能看出是哪一层的问题。下面给一个最小可用的自定义封装思路,实际项目里可以按需扩展。

from langchain_core.language_models.llms import LLM from typing import Any, List, Optional import requests class InternLMClient(LLM): endpoint: str = "http://127.0.0.1:8000/v1/chat/completions" model_name: str = "internlm2-chat-7b" temperature: float = 0.1 max_tokens: int = 1024 @property def _llm_type(self) -> str: return "internlm-custom" def _call(self, prompt: str, stop: Optional[List[str]] = None, **kwargs: Any) -> str: payload = { "model": self.model_name, "messages": [{"role": "user", "content": prompt}], "temperature": self.temperature, "max_tokens": self.max_tokens, } resp = requests.post(self.endpoint, json=payload, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]

这段代码的重点不在长短,而在于它把模型调用收敛到一个可控的入口。温度设低是有意为之——知识库问答要的是稳定复现,不是创意发挥,0.1 到 0.3 之间比较合适,太高了同一个问题两次回答可能给出不同条款,用户会懵。

2. 文档进库前的处理:决定成败的隐形环节

很多人把注意力全放在模型和检索上,结果系统答不准,回头一查,问题出在文档处理阶段。这一环没有炫技空间,但它是整个系统的地基。地基歪了,上面盖得再漂亮也白搭。

2.1 加载环节:不同格式的坑各不相同

先说 PDF,这是知识库最常见的格式,也是最容易出问题的。文字版 PDF 用常规解析器基本没问题,但遇到双栏排版、页眉页脚、脚注混排,解析出来的文本顺序会乱,一句话被拆到两处,检索时自然对不上。我的做法是解析完之后做一轮清洗:去掉重复出现的页眉页脚文本、合并被硬换行打断的句子、过滤掉纯页码行。

扫描版 PDF 更麻烦,得走 OCR。这时候要接受一个现实——OCR 有错误率,专业术语、生僻字、表格里的数字都可能识别错。如果文档里数字很关键(比如金额、规格参数),OCR 结果必须人工抽检。我一般会随机抽 5% 的页面比对原文,错误率超过阈值就换 OCR 方案或者干脆人工录入关键部分。

Word 文档相对友好,但要注意表格。表格被解析成纯文本后,行列关系就丢了,原本"型号 A 对应参数 3.5"变成一串没有结构的词,检索能命中但模型理解不了对应关系。对表格密集的文档,我会单独把表格抽出来转成 Markdown 表格再入库,保留结构信息。

Markdown 和纯文本最省心,但也要注意编码问题。遇到过 GBK 编码的老文件,直接读进来全是乱码,得先探测编码再解码。这个细节不起眼,但批量处理时一旦漏掉,那批文档就等于白入库了。

2.2 切分策略:块大小不是拍脑袋定的

文本切分是知识库调优里最容易被低估的环节。切太大,一个块里混着好几个主题,检索命中后模型被无关信息干扰;切太小,一个完整的意思被拆散,检索到的片段缺头少尾,模型只能瞎猜。

我的经验是先看文档类型再定参数。制度类、说明类文档,段落本身就是一个完整语义单元,按段落切、块大小控制在 500 到 800 字符比较合适,重叠留 50 到 100 字符防止边界信息丢失。技术手册、API 文档这种条目密集的,块可以小一些,300 到 500 字符,因为每个条目相对独立。

LangChain 的递归字符切分器有个好处,它会按分隔符优先级依次尝试——先按双换行(段落)切,切出来还太大再按单换行,再不行按句号,最后才硬切。这个回退逻辑保证了尽量在语义边界处断开。配置大概长这样:

from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=600, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], length_function=len, )

注意分隔符列表里把中文标点加进去了,默认配置是英文标点,处理中文文档时句子边界识别会不准。这个细节不改,切出来的块经常在半句话处断开。

还有一个进阶技巧:给每个块加上元数据。来源文件名、章节标题、页码这些信息跟着块一起存进向量库,检索时可以按来源过滤,回答时也能标注出处。用户看到"根据《XX 制度》第 3 章"这样的引用,信任度会高很多。元数据的添加在 LangChain 里通过 Document 对象的 metadata 字段实现,加载时顺手塞进去就行。

2.3 向量化:embedding 模型的选择逻辑

文档切好块,下一步是转成向量存进向量库。embedding 模型的选择直接影响检索质量,但很多人随便找个默认的就上了。

选 embedding 模型看三点:语言适配、维度、以及和你的检索场景是否匹配。中文知识库优先选在中文语料上训练过的模型,纯英文模型处理中文时语义区分度会下降。维度方面,768 维和 1024 维在实际效果上差距没有想象中大,但维度越高存储和检索开销越大,文档量大的时候要算这笔账。

我一般会做个小规模对比测试:准备 20 到 30 个真实问题,每个问题人工标注出应该命中的文档块,然后分别用候选 embedding 模型跑检索,看命中率。这个测试半天能做完,但能避免后面大量返工。测试时注意问题和文档要用同一套 embedding 模型,混用会导致向量空间不一致,检索结果完全不可用——这个错误我见过不止一次,排查起来还挺费劲,因为系统不报错,只是答得莫名其妙。

向量库的选择相对次要,文档量在十万块以内,轻量级的本地向量库完全够用,部署简单、无需额外服务。量再大再考虑专门的向量数据库。初期别过度设计,先把链路跑通。

3. 检索链的组装与提示词设计

文档进了库,模型能调用了,中间这层检索链是把两者接起来的关键。这一层设计得好,模型拿到的上下文精准,回答质量自然上去;设计得糙,模型再强也只能在垃圾上下文里挣扎。

3.1 检索策略:相似度检索不够用的时候

最基础的检索是向量相似度 top-k,给个 k 值(比如 4),取最相似的几个块拼进上下文。简单有效,但有两个明显短板。

一是语义相似不等于答案相关。用户问"报销流程要几天",向量检索可能召回一堆讲"报销"的段落,但真正讲"时限"的那段可能因为用词不同排在后面。二是单一检索对措辞敏感,用户换个说法,召回结果可能差很多。

改进方案有几个层次。最轻量的是加大 k 值再加重排——先召回 10 到 20 个候选,用一个重排模型按与问题的相关性重新排序,取前几个。重排模型比 embedding 模型更精细,能捕捉问题与文档块之间的细粒度匹配关系,代价是多一次模型调用。实测下来,加了重排之后命中率提升明显,尤其是问题表述和文档表述差异较大的场景。

再进一步是混合检索,向量检索和关键词检索并行,结果融合。关键词检索(比如 BM25)对专有名词、编号、精确术语特别有效,这些恰恰是向量检索的弱项——向量模型容易把"型号 A-102"和"型号 A-103"当成语义相近的东西。混合检索把两路结果按权重合并,能覆盖更多情况。LangChain 里有现成的 EnsembleRetriever 可以组合多个检索器,配置好权重即可。

from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 6}) bm25_retriever = BM25Retriever.from_documents(docs) bm25_retriever.k = 6 ensemble = EnsembleRetriever( retrievers=[vector_retriever, bm25_retriever], weights=[0.6, 0.4], )

权重怎么定?看你的文档特点。术语、编号多的,关键词检索权重大一些;叙述性、概念性的内容多,向量检索权重大一些。0.6 比 0.4 是个稳妥的起点,跑一批测试问题再微调。

3.2 上下文拼装:给模型的信息要克制

检索回来一堆块,不能一股脑全塞给模型。上下文窗口是有限的,塞太多会挤掉对话历史,而且无关信息越多,模型被带偏的概率越大。

我的做法是控制总量——拼装后的上下文控制在 2000 到 3000 字符,块数 3 到 5 个。每个块前面加上来源标记,方便模型引用,也方便你排查问题。块与块之间用明确的分隔符隔开,避免模型把不同来源的内容混成一段。

拼装顺序也有讲究。相关性最高的块放最前面还是最后面?实测下来放前面更稳,因为部分模型对上下文开头的注意力更强。但如果你用的模型有"中间信息丢失"的倾向(长上下文时中间部分容易被忽略),那就把最关键的块放开头和结尾,次要的放中间。

还有一个细节:如果检索回来的块之间内容重复度高(比如同一段话在多个文档里出现),要去重。重复内容不仅浪费上下文空间,还会让模型误以为这个信息特别重要,回答时过度强调。

3.3 提示词:把"基于文档回答"这件事说死

提示词是知识库问答的最后一公里。模型天生有"自由发挥"的倾向,提示词的任务就是把它摁在文档上。

核心指令要包含几层意思:只基于提供的上下文回答、上下文没有就说不知道、回答时标注来源、不要编造。这四条缺一不可。我见过只写"请根据以下内容回答"的提示词,模型遇到上下文没覆盖的问题时,照样能编出一段听起来很合理的答案。

一个经过实测的提示词模板大概是这样:

你是一个严谨的知识库助手。请严格基于下面提供的参考资料回答问题。 规则: 1. 只使用参考资料中的信息,不要引入参考资料之外的知识。 2. 如果参考资料中没有足够信息回答问题,直接回答"根据现有资料无法回答该问题",不要猜测。 3. 回答时尽量引用资料中的原文表述,并在句末标注来源编号,如[1][2]。 4. 如果问题涉及多个方面,分点作答,保持条理清晰。 参考资料: {context} 问题:{question} 回答:

这个模板里"不要猜测"和"无法回答"的兜底指令特别关键。知识库系统最怕的不是答不上来,而是答错了还言之凿凿。宁可让它说不知道,也不能让它编。

温度参数在这里也要配合,前面说了设 0.1 到 0.3。另外可以在调用时加 stop 词,比如遇到"问题:"就停,防止模型自问自答跑偏。

4. 跑通之后:那些只有实际部署才会遇到的问题

Demo 跑通和系统能用之间,隔着一条叫"现实"的沟。下面这些问题是实际部署时才会冒出来的,提前知道能省不少时间。

4.1 响应速度:慢在哪一层要能定位

知识库问答的响应时间由几段构成:检索耗时、模型推理耗时、网络传输耗时。用户感知的是总时间,但你排查时必须能拆开看。

检索耗时通常在几十到几百毫秒,向量库规模大了会上升,但一般不是瓶颈。模型推理是大头,7B 模型在消费级显卡上生成 200 字左右的回答,几秒到十几秒都正常。如果用了重排模型,还要再加一次模型调用的时间。

优化手段按性价比排序:第一,流式输出。让模型边生成边返回,用户看到字一个个蹦出来,感知等待时间大幅下降。LangChain 的流式回调配合前端渲染就能实现,改动不大效果明显。第二,控制生成长度。知识库回答不需要长篇大论,max_tokens 设 512 到 1024 足够,设太大模型容易啰嗦。第三,检索和模型调用可以并行化的部分尽量并行,比如混合检索的两路可以同时发起。

有个坑要注意:流式输出时如果模型中途开始编造,你没法像非流式那样在返回前拦截。所以流式场景下提示词的约束要更严,或者在前端加个"停止生成"的按钮给用户兜底。

4.2 答非所问的排查链路

系统上线后最常见的反馈是"答得不对"。这时候别急着调模型,按链路从后往前查。

先看检索结果。把用户的问题拿去检索,人工看召回的前几个块里有没有正确答案。如果没有,问题在检索层——可能是切分把答案切散了,可能是 embedding 模型对这类问题不敏感,也可能是关键词没覆盖到。如果有正确答案但模型没用上,问题在拼装或提示词层——上下文太长把关键信息淹没了,或者提示词约束不够。

再看模型输出。如果检索结果正确、上下文也包含答案,模型还是答错,那可能是模型能力问题或者提示词有歧义。这时候可以试着把上下文精简到只留最相关的块,看模型能不能答对,以此判断是信息过载还是理解偏差。

我整理了一个排查对照表,实际用起来挺顺手:

现象优先排查层常见原因处理方向
召回结果里没有正确答案检索层切分过碎、embedding 不适配、关键词缺失调整切分参数、换 embedding、加混合检索
召回有答案但模型没用拼装/提示词层上下文过长、关键块位置靠后、指令不明确精简上下文、调整块顺序、强化提示词
模型答错且上下文无答案提示词层兜底指令缺失、温度过高加"无法回答"指令、降温度
同一问题回答不稳定模型层温度过高、上下文有冲突信息降温度、去重、检查文档一致性

这张表的价值在于把"答得不对"这个模糊反馈拆成可操作的排查方向,避免上来就怀疑模型。

4.3 文档更新了怎么办

知识库不是一次性的,文档会增删改。全量重建索引简单粗暴,但文档量大了耗时不可接受。增量更新是必须支持的。

新增文档好办,走一遍加载、切分、向量化、入库的流程就行。删除文档要记得同时删向量库里的对应记录,否则会出现"文档已删但还能检索到"的诡异情况。修改文档最麻烦,得先按来源标识把旧块删掉再插入新块,这个逻辑要写扎实,不然容易留下孤儿块。

元数据里的来源标识要设计得能唯一定位文档,用文件路径加修改时间是个稳妥方案。每次更新时比对修改时间,只处理变动的文档。这套机制不复杂,但省下的重建时间很可观。

4.4 权限与多知识库隔离

稍微正式一点的场景就会遇到权限问题——不同部门的人只能查自己部门的文档。这个需求在架构上要提前考虑,事后加会很痛苦。

简单做法是按知识库分库,每个部门一个独立的向量库集合,检索时根据用户身份路由到对应集合。LangChain 的向量库接口支持按 metadata 过滤,也可以在一个大库里用部门标签过滤,但分库在隔离性和性能上更干净。

提示词层面也要配合,确保模型不会把 A 库的内容泄露给 B 库的用户。这个靠检索层隔离就够了,因为模型只能看到检索回来的上下文,检索层不返回越权内容,模型就无从泄露。

5. 让系统更好用的几个进阶方向

基础版本跑稳之后,有几个方向能明显提升体验,投入产出比都不错。

5.1 多轮对话的上下文管理

单轮问答够用,但用户天然会追问。"那这个流程要谁审批?"——这个问题脱离了上一轮的上下文就没法理解。支持多轮需要把对话历史带进提示词,但不能无限带,否则上下文窗口很快被占满。

我的做法是保留最近 3 到 5 轮对话,更早的做摘要压缩。另外,追问场景下检索也要调整——不能只拿当前这句话去检索,要把上一轮的问题和当前问题合并成一个检索查询,这样召回的内容才连贯。LangChain 里有 ConversationBufferMemory 之类的组件可以管理历史,但检索查询的改写要自己处理,一个简单的做法是用模型把"当前问题 + 上一轮问题"改写成独立的检索语句。

5.2 引用溯源做到块级别

用户对答案的信任,很大程度来自能不能验证。回答里标注来源还不够,最好能点开看到原文片段。这要求每个块在入库时保留足够的定位信息——文件名、页码、在原文中的起止位置。

实现上,检索回来的块带着 metadata,拼装上下文时把 metadata 编码进来源标记,模型引用时带上标记,前端解析标记后展示对应原文。这套链路打通之后,用户看到回答旁边有个"查看出处",点开就是原文高亮,体验会好很多。

5.3 效果评估:别靠感觉判断好坏

系统好不好,不能靠偶尔问几个问题拍脑袋。建一个小的评估集——50 到 100 个真实问题,每个标注标准答案或至少标注应该命中的文档块。每次调整参数、换模型、改切分策略,都跑一遍评估集,看命中率和回答准确率的变化。

评估指标不用太复杂,检索命中率(召回块里有没有正确答案)和回答正确率(人工判断或用一个强模型做裁判)两个就够。关键是坚持跑,把每次改动的影响量化出来。我见过太多项目调参全靠感觉,改了半天不知道是变好了还是变差了。

评估集要覆盖不同类型的问题:事实型(某个参数是多少)、流程型(某件事怎么办)、对比型(A 和 B 有什么区别)、以及应该拒答的问题(知识库里根本没有的内容)。最后一类特别重要,专门检验兜底逻辑有没有生效。

5.4 一个容易忽略的细节:文档质量本身

最后说个反直觉的:知识库答不准,有时候不是技术问题,是文档本身写得乱。同一件事在不同文档里说法不一致、过时的文档没清理、关键信息散落在多个文件里——这些靠技术手段很难补救。

上线前花时间梳理文档,合并重复内容、标注废弃文档、把关键信息集中到少数几份权威文档里,这个投入比调参更值。技术能解决"找得到"的问题,解决不了"源头就矛盾"的问题。

我在实际项目里的体会是,知识库系统七分靠文档治理,三分靠技术调优。技术方案选对了能保证下限,但上限是由文档质量决定的。所以每次有人问我为什么答不准,我第一句总是先问:你确认那几份文档本身说的是同一件事吗?

这套基于 InternLM 和 LangChain 的方案,从选型到部署再到调优,核心逻辑其实就一条:让模型在正确的信息范围内工作。检索负责"找对信息",提示词负责"用对信息",评估负责"确认用对了"。三个环节都扎实,系统才真正可用。至于那些花哨的功能,等基础链路稳了再往上加,顺序别搞反。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询