Building an AI Knowledge Base — Internal Documentation Search That Actually Works
Advertisement
Introduction
Most companies have documentation scattered across Confluence, Notion, GitHub, and Google Docs — where employees find outdated guides, duplicate information, or nothing at all. An AI knowledge base unifies fragmented documentation into a single conversational interface, but building one that works in production requires solving ingestion pipelines, incremental sync, access control, and quality feedback loops simultaneously.
Why Most Knowledge Bases Fail
The typical failure mode is not technical — it is freshness. A knowledge base indexed once and never updated becomes worse than useless because employees stop trusting it after finding stale answers. The second failure is access control: surfacing a restricted HR document to the wrong employee destroys trust instantly.
Production knowledge bases need three things: automated ingestion that keeps content current, namespace-level access control enforced at query time, and a feedback loop that flags bad answers before users give up.
Unified Ingestion Pipeline
Build one ingestion layer that normalizes documents from all sources into a common schema:
import hashlib
import json
from dataclasses import dataclass, field
from typing import Literal
from datetime import datetime
@dataclass
class Document:
id: str
source: Literal["confluence", "notion", "github", "gdocs"]
source_id: str
title: str
content: str
url: str
last_modified: datetime
owner: str
access_level: Literal["public", "team", "restricted"]
tags: list[str] = field(default_factory=list)
def content_checksum(self) -> str:
return hashlib.sha256(self.content.encode()).hexdigest()
def ingest_confluence(domain: str, token: str, space_keys: list[str]) -> list[Document]:
docs = []
for space_key in space_keys:
import requests
resp = requests.get(
f"https://{domain}/wiki/rest/api/content",
params={"spaceKey": space_key, "expand": "body.storage,version,restrictions"},
headers={"Authorization": f"Bearer {token}"},
)
for page in resp.json().get("results", []):
docs.append(Document(
id=f"confluence-{page['id']}",
source="confluence",
source_id=page["id"],
title=page["title"],
content=page["body"]["storage"]["value"],
url=f"https://{domain}/wiki/spaces/{space_key}/pages/{page['id']}",
last_modified=datetime.fromisoformat(page["version"]["when"].rstrip("Z")),
owner=page["version"]["by"]["username"],
access_level="team",
tags=page.get("labels", {}).get("results", []),
))
return docsIncremental Sync With Content Checksums
Never re-ingest everything. Sync only what changed:
def incremental_sync(
existing_checksums: dict[str, str],
new_docs: list[Document],
) -> dict:
added, updated, deleted = [], [], []
new_checksum_map = {}
for doc in new_docs:
checksum = doc.content_checksum()
new_checksum_map[doc.id] = checksum
if doc.id not in existing_checksums:
added.append(doc)
elif existing_checksums[doc.id] != checksum:
updated.append(doc)
# else: unchanged — skip
for doc_id in existing_checksums:
if doc_id not in new_checksum_map:
deleted.append(doc_id)
return {"added": added, "updated": updated, "deleted": deleted}
def apply_sync(changes: dict, vector_db) -> None:
for doc in changes["added"]:
vector_db.upsert(doc.id, doc.content, {
"title": doc.title,
"url": doc.url,
"access_level": doc.access_level,
"last_modified": doc.last_modified.isoformat(),
})
for doc in changes["updated"]:
vector_db.upsert(doc.id, doc.content, {
"title": doc.title,
"url": doc.url,
})
for doc_id in changes["deleted"]:
vector_db.delete(doc_id)Checksum-based diffing is significantly more reliable than timestamp comparison because timestamps are frequently incorrect after bulk migrations or source system clock drift.
Access-Controlled Retrieval
Enforce document permissions at query time, not at ingestion time:
def search_with_access(
query: str,
user_id: str,
user_teams: list[str],
vector_db,
limit: int = 5,
) -> list[dict]:
# Fetch more candidates than needed to account for access filtering
candidates = vector_db.search(query, limit=limit * 4)
accessible = []
for doc in candidates:
level = doc["metadata"]["access_level"]
if level == "public":
accessible.append(doc)
elif level == "team":
doc_teams = get_document_teams(doc["id"])
if any(t in doc_teams for t in user_teams):
accessible.append(doc)
elif level == "restricted":
allowed_users = get_restricted_users(doc["id"])
if user_id in allowed_users:
accessible.append(doc)
if len(accessible) >= limit:
break
return accessibleAlways filter after retrieval, never rely on namespace separation alone. Namespaces help performance; permission checks provide security.
Conversational Search With Source Attribution
The interface must show where answers come from:
def answer_question(
query: str,
user_id: str,
user_teams: list[str],
vector_db,
llm_client,
) -> dict:
docs = search_with_access(query, user_id, user_teams, vector_db)
if not docs:
return {
"answer": "No relevant documentation found. Try rephrasing your question or contact your knowledge manager.",
"sources": [],
}
context_parts = [
f"[{doc['metadata']['title']}]({doc['metadata']['url']}):\n{doc['content'][:600]}"
for doc in docs
]
context = "\n\n---\n\n".join(context_parts)
prompt = f"""You are a knowledge base assistant. Answer the question using only the provided documentation.
Question: {query}
Documentation:
{context}
Rules:
- Answer in 2-4 sentences
- Cite the source document by name with a link
- If the documentation does not fully answer the question, say so explicitly
- Do not invent information not present in the sources"""
answer = llm_client.generate(prompt)
return {
"answer": answer,
"sources": [
{"title": d["metadata"]["title"], "url": d["metadata"]["url"]}
for d in docs
],
}Staleness Detection
Proactively flag outdated documents before users encounter them:
def detect_stale_documents(all_docs: list[dict]) -> list[dict]:
stale = []
now = datetime.utcnow()
for doc in all_docs:
last_modified = datetime.fromisoformat(doc["last_modified"])
age_days = (now - last_modified).days
reasons = []
if age_days > 365:
reasons.append(f"Not updated in {age_days} days")
# Look for year references that are now stale
import re
stale_years = re.findall(r"as of (202[0-3])", doc["content"], re.IGNORECASE)
if stale_years:
reasons.append(f"Contains stale year reference: {stale_years[0]}")
if reasons:
stale.append({
"id": doc["id"],
"title": doc["title"],
"age_days": age_days,
"reasons": reasons,
"action": "Review and update or archive",
})
return staleSearch Analytics to Find Documentation Gaps
Track what employees search for and fail to find:
def log_search_event(
query: str,
user_id: str,
result_count: int,
clicked: bool,
dwell_seconds: float,
analytics_db,
) -> None:
analytics_db.insert("search_events", {
"query": query,
"user_id": user_id,
"timestamp": datetime.utcnow().isoformat(),
"result_count": result_count,
"clicked": clicked,
"dwell_seconds": dwell_seconds,
})
# Zero-result searches point directly to documentation gaps
if result_count == 0:
analytics_db.insert("zero_result_queries", {
"query": query,
"timestamp": datetime.utcnow().isoformat(),
})
def top_zero_result_queries(analytics_db, days: int = 30) -> list[dict]:
return analytics_db.query(
"""
SELECT query, COUNT(*) as count
FROM zero_result_queries
WHERE timestamp > NOW() - INTERVAL %s DAY
GROUP BY query
ORDER BY count DESC
LIMIT 20
""",
[days],
)Feedback Loop for Document Quality
Let users flag unhelpful answers:
def record_feedback(
doc_id: str,
user_id: str,
helpful: bool,
comment: str,
feedback_db,
threshold_rate: float = 0.6,
min_feedback: int = 5,
) -> None:
feedback_db.insert("document_feedback", {
"doc_id": doc_id,
"user_id": user_id,
"helpful": helpful,
"comment": comment,
"timestamp": datetime.utcnow().isoformat(),
})
# Check if this document has a consistently low helpfulness rate
recent = feedback_db.query(
"SELECT helpful FROM document_feedback WHERE doc_id = %s AND timestamp > NOW() - INTERVAL 30 DAY",
[doc_id],
)
if len(recent) >= min_feedback:
helpful_rate = sum(1 for r in recent if r["helpful"]) / len(recent)
if helpful_rate < threshold_rate:
flag_document_for_review(doc_id, helpful_rate, feedback_db)Key Takeaways
- Checksum-based incremental sync is more reliable than timestamp comparison — sync only documents whose content hash has changed.
- Access control must be enforced at query time by filtering retrieved candidates, not by namespace separation alone, to guarantee that permission changes take effect immediately.
- Conversational search always cites source documents with links — users who cannot verify answers will stop trusting the system.
- Staleness detection on documents older than 365 days, or containing year references from prior years, prevents stale content from reaching users.
- Zero-result query tracking is the fastest way to identify documentation gaps — every failed search is a content request.
- A feedback loop that flags documents with under 60% helpfulness from five or more responses creates a self-improving quality signal.
- Incremental sync schedules should match document churn: hourly for Confluence spaces with active editing, daily for static GitHub wikis.
- A knowledge base with good access control but stale content is worse than no knowledge base — prioritize freshness infrastructure before expanding coverage.
Advertisement