RAG 本地知识库问答实战:LangChain 接入文档检索,用 cpolar 远程演示带引用答案

RAG 本地知识库问答实战:LangChain 接入文档检索,用 cpolar 远程演示带引用答案 RAG 本地知识库问答实战LangChain 接入文档检索用 cpolar 远程演示带引用答案本地知识库问答最容易卡在两个地方一是模型答得像“凭感觉总结”看不到依据二是服务只能在localhost打开想让同事或手机试一下还要截图、录屏来回解释。这篇文章不讲模型部署入门也不讲 AI 助手权限安全边界。我们只做一件可落地的事把一批本地文档切分、向量化做成一个带引用来源的 RAG 问答服务然后用 cpolar 暴露一个临时演示地址让异地同事直接打开页面测试。本文主线如下准备本地文档文档切分向量化并写入 Chroma启动本地 FastAPI 问答页面和 API验证答案引用依据用 cpolar 分享7860端口的演示地址安全边界先说清楚本文只暴露演示问答服务不暴露向量库管理端不提供上传、删除、重建索引等无鉴权写接口。演示结束后关闭 cpolar 隧道。一、最终效果完成后本机启动一个服务Web 页面http://127.0.0.1:7860API 接口POST http://127.0.0.1:7860/ask公网演示通过cpolar http 7860生成临时访问地址页面输入问题后返回内容包含两部分answer模型基于检索片段生成的回答sources回答引用的文档名、页码或段落位置、原文片段二、环境准备本文用 Python LangChain Chroma Ollama 跑通最小可用版本。Ollama 只作为本地模型服务使用重点不在模型安装而在 RAG 检索、引用和演示分享。1. 创建项目目录mkdir rag-local-demo cd rag-local-demo mkdir docs storage2. 创建虚拟环境macOS / Linuxpython3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pipWindows PowerShellpython -m venv .venv .\.venv\Scripts\Activate.ps1 python -m pip install --upgrade pip3. 安装依赖pip install fastapi uvicorn chromadb pypdf langchain langchain-community langchain-chroma langchain-ollama python-dotenv4. 准备本地模型本文示例使用 Ollama 的本地对话模型和向量模型ollama pull qwen2.5:7b ollama pull nomic-embed-text确认 Ollama 服务可用ollama list能看到qwen2.5:7b和nomic-embed-text就可以进入 RAG 部分。三、准备文档把要问答的资料放进docs/目录。为了方便验证先放一个 Markdown 示例。创建docs/company_faq.md内容如下# 内部知识库示例 ## 报销规则 差旅报销需要在返程后 7 个自然日内提交。发票抬头必须与公司主体一致。单笔超过 500 元的交通费用需要附行程单。 ## 远程演示规则 内部演示服务只能暴露只读页面或只读 API。禁止将管理后台、数据库控制台、向量库写入接口直接暴露到公网。 ## 客户交付材料 客户交付前需要完成自测记录、版本号确认和回滚方案确认。交付文档必须包含部署步骤、验证步骤和联系人。也可以把 PDF 放进docs/后面的脚本会读取.md、.txt和.pdf。四、写入配置文件创建.envcat .env EOF OLLAMA_BASE_URLhttp://127.0.0.1:11434 CHAT_MODELqwen2.5:7b EMBED_MODELnomic-embed-text CHROMA_DIRstorage/chroma DEMO_TOKENchange-this-demo-token EOFDEMO_TOKEN用于保护演示接口。公开演示时把这个 token 单独发给测试同事不要写在页面标题、截图或文章评论区里。五、构建向量索引创建index_docs.pyfrom pathlib import Path from pypdf import PdfReader from dotenv import load_dotenv import os from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_chroma import Chroma from langchain_ollama import OllamaEmbeddings load_dotenv() DOCS_DIR Path(docs) CHROMA_DIR os.getenv(CHROMA_DIR, storage/chroma) EMBED_MODEL os.getenv(EMBED_MODEL, nomic-embed-text) OLLAMA_BASE_URL os.getenv(OLLAMA_BASE_URL, http://127.0.0.1:11434) def load_documents() - list[Document]: documents: list[Document] [] for path in sorted(DOCS_DIR.glob(**/*)): if path.is_dir(): continue suffix path.suffix.lower() if suffix in {.md, .txt}: text path.read_text(encodingutf-8) documents.append(Document(page_contenttext, metadata{source: str(path), page: 1})) elif suffix .pdf: reader PdfReader(str(path)) for page_no, page in enumerate(reader.pages, start1): text page.extract_text() or if text.strip(): documents.append(Document(page_contenttext, metadata{source: str(path), page: page_no})) return documents def main() - None: raw_docs load_documents() if not raw_docs: raise SystemExit(docs/ 目录没有可索引的 .md、.txt 或 .pdf 文件) splitter RecursiveCharacterTextSplitter(chunk_size600, chunk_overlap120) chunks splitter.split_documents(raw_docs) embeddings OllamaEmbeddings(modelEMBED_MODEL, base_urlOLLAMA_BASE_URL) Chroma.from_documents( documentschunks, embeddingembeddings, persist_directoryCHROMA_DIR, collection_namelocal_docs, ) print(findexed raw_docs{len(raw_docs)} chunks{len(chunks)} dir{CHROMA_DIR}) if __name__ __main__: main()执行索引python index_docs.py看到类似输出即完成indexed raw_docs1 chunks3 dirstorage/chroma这一步完成后storage/chroma里已经有本地向量库数据。六、启动带引用的问答服务创建app.pyimport os from typing import Any from dotenv import load_dotenv from fastapi import FastAPI, Header, HTTPException from fastapi.responses import HTMLResponse from pydantic import BaseModel from langchain_chroma import Chroma from langchain_ollama import OllamaEmbeddings, ChatOllama from langchain_core.prompts import ChatPromptTemplate load_dotenv() OLLAMA_BASE_URL os.getenv(OLLAMA_BASE_URL, http://127.0.0.1:11434) CHAT_MODEL os.getenv(CHAT_MODEL, qwen2.5:7b) EMBED_MODEL os.getenv(EMBED_MODEL, nomic-embed-text) CHROMA_DIR os.getenv(CHROMA_DIR, storage/chroma) DEMO_TOKEN os.getenv(DEMO_TOKEN, change-this-demo-token) app FastAPI(titleLocal RAG Demo) embeddings OllamaEmbeddings(modelEMBED_MODEL, base_urlOLLAMA_BASE_URL) vectorstore Chroma( collection_namelocal_docs, embedding_functionembeddings, persist_directoryCHROMA_DIR, ) retriever vectorstore.as_retriever(search_kwargs{k: 4}) llm ChatOllama(modelCHAT_MODEL, base_urlOLLAMA_BASE_URL, temperature0) prompt ChatPromptTemplate.from_messages([ (system, 你是一个本地知识库问答助手。只根据给定资料回答资料不足时直接说资料中没有找到。回答后列出引用依据。), (human, 问题{question}\n\n资料\n{context}) ]) class AskRequest(BaseModel): question: str def check_token(x_demo_token: str | None) - None: if x_demo_token ! DEMO_TOKEN: raise HTTPException(status_code401, detailinvalid demo token) def format_docs(docs: list[Any]) - str: parts [] for i, doc in enumerate(docs, start1): source doc.metadata.get(source, unknown) page doc.metadata.get(page, -) parts.append(f[{i}] source{source} page{page}\n{doc.page_content}) return \n\n.join(parts) app.get(/, response_classHTMLResponse) def home() - str: return !doctype htmlhtmlheadmeta charsetutf-8titleLocal RAG Demo/title/head body stylemax-width:880px;margin:40px auto;font-family:Arial, sans-serif;line-height:1.6 h2Local RAG Demo/h2 p输入 Demo Token 和问题服务会返回答案与引用片段。/p input idtoken placeholderDemo Token stylewidth:100%;padding:8pxbrbr textarea idq rows4 stylewidth:100%;padding:8px placeholder例如远程演示服务能暴露哪些接口/textareabrbr button onclickask()提问/button pre idout stylewhite-space:pre-wrap;background:#f6f8fa;padding:16px/pre script async function ask(){ const res await fetch(/ask, { method:POST, headers:{Content-Type:application/json,X-Demo-Token:document.getElementById(token).value}, body:JSON.stringify({question:document.getElementById(q).value}) }); document.getElementById(out).textContent JSON.stringify(await res.json(), null, 2); } /script/body/html app.post(/ask) def ask(req: AskRequest, x_demo_token: str | None Header(defaultNone)) - dict[str, Any]: check_token(x_demo_token) docs retriever.invoke(req.question) context format_docs(docs) answer llm.invoke(prompt.format_messages(questionreq.question, contextcontext)).content sources [ { source: doc.metadata.get(source, unknown), page: doc.metadata.get(page, -), snippet: doc.page_content[:220], } for doc in docs ] return {answer: answer, sources: sources}启动服务uvicorn app:app --host 127.0.0.1 --port 7860这里故意绑定127.0.0.1表示服务只监听本机。后面需要远程演示时再用 cpolar 暴露这个端口。七、本地验证引用依据打开浏览器访问http://127.0.0.1:7860输入.env里的DEMO_TOKEN再输入问题远程演示服务能暴露哪些接口如果用 API 测试可以执行curl -s http://127.0.0.1:7860/ask \ -H Content-Type: application/json \ -H X-Demo-Token: change-this-demo-token \ -d {question:远程演示服务能暴露哪些接口}返回结果会包含sources其中能看到来自docs/company_faq.md的片段例如{ answer: 内部演示服务只能暴露只读页面或只读 API不能暴露管理后台、数据库控制台或向量库写入接口。引用依据[1]。, sources: [ { source: docs/company_faq.md, page: 1, snippet: 内部演示服务只能暴露只读页面或只读 API。禁止将管理后台、数据库控制台、向量库写入接口直接暴露到公网。 } ] }验证 RAG 是否真的生效看三点就够sources里有文档来源不是只有一段自然语言回答。回答内容能对应到snippet不是脱离资料自由发挥。问一个资料里没有的问题服务会回答“资料中没有找到”而不是编一个结论。例如继续问公司食堂几点开门示例文档里没有食堂信息合格回答应明确说明资料中没有找到。八、用 cpolar 分享远程演示地址本地验证通过后再开一个终端执行cpolar http 7860命令启动后终端会显示公网访问地址。把https://...这一条发给同事对方就能访问本机的 RAG 演示页面。如果你的 cpolar 客户端已经以后台服务方式运行也可以打开本地 Web UI 查看在线隧道http://127.0.0.1:9200这一步的定位很简单RAG 服务仍然跑在本机127.0.0.1:7860cpolar 只负责把这个演示端口临时映射到公网。手机、异地同事、客户预览环境都可以用这个地址快速验证交互效果。演示时建议只发送三样内容cpolar 生成的 HTTPS 地址Demo Token测试问题示例不要发送服务器目录、向量库路径、后台管理地址和无关端口。九、演示服务的安全收口RAG 演示常见风险不是“别人能不能打开页面”而是“打开之后能不能做不该做的事”。本文的示例按只读演示设计/ask需要X-Demo-TokenWeb 页面只调用/ask没有提供上传文档接口没有提供重建索引接口没有暴露 Chroma 管理端uvicorn 绑定127.0.0.1远程访问只经过指定的 cpolar 隧道演示结束后按Ctrl C关闭cpolar http 7860公网地址随即失效。免费随机地址会变化适合临时演示需要固定地址时再按团队要求配置固定域名或固定隧道。如果要给更多人测试建议增加两层控制把DEMO_TOKEN改成更长的随机字符串。在应用层记录提问日志但不要记录用户输入的隐私信息和密钥。可以用下面命令生成随机 tokenpython - PY import secrets print(secrets.token_urlsafe(32)) PY替换.env后重启服务即可。十、常见问题排查1.python index_docs.py报 Ollama 连接失败先确认 Ollama 服务在本机运行ollama list如果命令无法返回模型列表先启动 Ollama再重新执行索引脚本。2. 页面能打开但回答很慢本地模型推理速度取决于机器配置和模型大小。先保持k4、chunk_size600不要一次检索太多片段。需要更快响应时换更小的对话模型或把演示问题控制在知识库范围内。3. 返回没有引用检查三个位置ls docs ls storage/chroma python index_docs.py确保docs/里有文档并且重新执行过索引。新增文档后必须重新运行python index_docs.py。4. cpolar 地址打开后提示 401这是正常的鉴权结果。页面里需要填写.env中的DEMO_TOKENAPI 调用需要带X-Demo-Token请求头。5. cpolar 地址能打开页面但提问失败先在本机测试接口curl -s http://127.0.0.1:7860/ask \ -H Content-Type: application/json \ -H X-Demo-Token: change-this-demo-token \ -d {question:报销规则是什么}本机接口正常再检查 cpolar 终端里的公网地址是否复制完整以及浏览器页面里 token 是否填写正确。十一、扩展方向这个最小版本已经具备 RAG 演示所需的核心能力本地资料、向量检索、带来源回答、Web/API 访问和公网临时分享。后续可以按需求扩展把 Markdown、PDF 之外的 Word、HTML 接入解析流程给每个团队或项目建立独立 collection把引用片段做成可点击的文档定位增加只读审计日志方便复盘哪些问题没有命中资料在正式环境前接入统一登录而不是只用 demo token不要一开始就把系统做成“大而全知识库平台”。先用这套流程把一个小目录跑通确认回答质量、引用依据和远程演示链路都成立再决定是否接入更多文档类型和权限体系。总结这篇文章完成了一条完整的本地 RAG 问答链路本地文档 - 文档切分 - Ollama Embedding - Chroma 向量库 - LangChain 检索 - 本地模型生成 - FastAPI Web/API - cpolar 临时演示地址关键点不在于堆模型参数而在于让答案有依据、让别人能快速测试、让演示边界可控。只要坚持“只暴露演示服务不暴露管理端和写接口”本地知识库问答就可以既方便展示也保留必要的安全边界。